实时渲染(一)绘制一个三角形

实时渲染

本文旨在通过精简实用的方式,结合移动端开发的特点,提供一个易于上手的 OpenGL 学习路径。
我们将重点关注与移动端应用相关的核心功能实现,帮助开发者在短时间内掌握基础知识,并快速应用到实际项目中。

我们抛开自定义渲染管线,哪些让人云里雾里的 OpenGL 状态机设置,让人头疼的 C++ 工程编译配置,
在学习之初就要直击核心效果的绘制。

一个常见的误区是学习渲染需要先掌握 C++ 语言,对于非 C++ 出身的 Android 同学来说,这是个可怕的错误,因为它会让初学的成本直接翻倍,在应用渲染的过程中顺便解决 C++ 更合适。等我们学会了 Shader 之后,再去想能不能有优化工程中的其他问题,包括语言。

市面上现有的渲染教程,常常有着大段大段的代码,和复杂的渲染管线图,这些对于初学者来说都是舍本逐末,徒增学习负担。
本教程将尽力避免这些对于初学者不友好的点,并尽可能简单的提供给大家直接能编译运行调试程序的环境。

定义第一个三角形

在 OpenGL 中,Shader 是运行在 GPU 上的小程序,用于控制图形渲染的各个阶段。常见的 Shader 类型包括:

  1. Vertex Shader:处理顶点数据,决定顶点位置和属性。
  2. Fragment Shader:处理像素数据,决定每个像素的颜色。

我们专注于决定 Shader 渲染需要的参数和函数

着色器

理论

着色器:运行在 GPU 上的小程序,用于控制图形渲染过程中的特定阶段,如顶点变换和像素着色。

着色器的作用:控制 GPU 渲染的顶点处理和像素着色。

着色器程序:将编译后的顶点着色器和片段着色器链接在一起,用于 GPU 渲染的一段可执行代码。

着色器就是渲染的核心,从这个概念也可以看出,我们要学习渲染编程,最主要就是要学习如何编写着色器程序。
着色器程序的英文就是 Shader。

工程

GLuint:无符号整数类型,用于表示 OpenGL 对象的唯一标识符。

1
typedef unsigned int GLuint;

着色器程序对象指针:一个 OpenGL 的 GLuint 标识符,用于引用和操作链接后的着色器程序。

定义着色器程序对象指针
1
GLuint m_ProgramObj;  ///< 着色器程序对象 ID
定义顶点着色器对象与片段着色器对象指针
1
2
GLuint m_VertexShader;  ///< 顶点着色器对象 ID
GLuint m_FragmentShader; ///< 片段着色器对象 ID
编写三角形着色器程序代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
void TriangleSample::Init() {
if (m_ProgramObj != 0)
return;
// 顶点着色器代码
char vShaderStr[] =
"#version 300 es \n" // 声明使用 OpenGL ES 3.0 版本
"layout(location = 0) in vec4 vPosition; \n" // 输入顶点位置,绑定到位置 0
"layout(location = 1) in vec3 vColor; \n" // 输入顶点颜色,绑定到位置 1
"out vec3 fColor; \n" // 输出到片段着色器的颜色变量
"void main() \n" // 主函数
"{ \n"
" gl_Position = vPosition; \n" // 将顶点位置传递给 gl_Position(OpenGL 内置变量)
" fColor = vColor; \n" // 将顶点颜色赋值给输出变量 fColor
"} \n";

// 片段着色器代码
char fShaderStr[] =
"#version 300 es \n" // 声明使用 OpenGL ES 3.0 版本
"precision mediump float; \n" // 指定默认精度为中等精度
"in vec3 fColor; \n" // 从顶点着色器接收的颜色变量
"out vec4 fragColor; \n" // 输出的片段颜色
"void main() \n" // 主函数
"{ \n"
" fragColor = vec4(fColor, 1.0); \n" // 将 fColor 赋值给 fragColor,同时指定 alpha 为 1.0
"} \n";

m_ProgramObj = GLUtils::CreateProgram(vShaderStr, fShaderStr, m_VertexShader, m_FragmentShader);
}

逐部分解析:

我们有以下的问题要回答:

  1. 什么是 vec3、vec4

vec3: 三维向量组

vec4: 四维向量组

  1. 怎么理解 in 和 out 和 main 函数

以 Kotlin 函数为例子

1
fun main(vPosition: vec4, vColor: vec3): vec3

vPosition 和 vColor 是函数入参 —— 对应了 in
vec3 是函数返回值 —— 对应了 out

在着色器程序中:
in 用于接收来自上一个阶段的数据
out 用于将数据传递给下一个阶段
main 函数是程序的入口

  1. layout(location = X) 是什么意思,怎么理解这个 layout
1
2
"layout(location = 0) in vec4 vPosition;  \n" // 输入顶点位置,绑定到位置 0
"layout(location = 1) in vec3 vColor; \n" // 输入顶点颜色,绑定到位置 1

理解 layout

顶点着色器是渲染管线的第一道程序,其 in 修饰的参数来自于外部,需要使用 layout 给这样的参数一个序号,以便外部传入。

定义一个彩色三角形

首先要明确一个概念,任何物体,不管是二维还是三维的,三点确定一个平面,无数的平面组成了空间的中的物体。

这也是为什么我们要从渲染一个三角形开始说起。

VBO 顶点缓冲区

我们把空间中的点叫做顶点,把保存顶点属性数据的数据结构叫做 顶点缓冲区(VBO)

  • 顶点缓冲区存储的是顶点数据(如位置、颜色等)。你将顶点数据上传到 GPU 上的缓冲区对象(VBO)中。每个顶点数据通常包括多个属性,这些属性是按照一定顺序存储的。例如,一个顶点可能包含位置(vec4)和颜色(vec3)。

按照我们的定义,每个顶点包含一个 vPosition 和 vColor,layout 就是定义顶点属性数据在顶点缓冲区中的位置。

VBO 就应该是

position (vec4) color (vec3)
0.0f, 0.5f, 0.0f, 1.0f 1.0f, 0.0f, 0.0f

有了上面对多维向量,in、out、layout 和 VBO 的概念之后,我们继续说顶点着色器与片段着色器

完整的传参的过程

首先我们看一下完整参数的传递过程

顶点着色器是渲染流水管线的第一步,它的参数是从外面传入的,这个我们待会说

1
2
"layout(location = 0) in vec4 vPosition;  \n"      // 输入顶点位置,绑定到位置 0
"layout(location = 1) in vec3 vColor;" // 输入顶点颜色,绑定到位置 1

同时它会将向下一个阶段也就是片段着色器中传递

1
"out vec3 fColor;                         \n"     // 输出到片段着色器的颜色变量

而片段着色器的参数是顶点着色器传入的

1
"in vec3 fColor;                              \n" // 从顶点着色器接收的颜色变量

同时它会向下一个阶段传递片段颜色

1
"out vec4 fragColor;                          \n" // 输出的片段颜色

现在我们定义一个 含有三个 VBO 的三角形

position (vec4) color (vec3) position (vec4) color (vec3) position (vec4) color (vec3)
0.0f, 0.5f, 0.0f, 1.0f 1.0f, 0.0f, 0.0f -0.5f, -0.5f, 0.0f, 1.0f 0.0f, 1.0f, 0.0f 0.5f, -0.5f, 0.0f.1.0f 0.0f, 0.0f, 1.0f

代码中应该这样写

1
2
3
4
5
6
7
// 定义三角形的顶点数据,包括位置和颜色
GLfloat vVertices[] = {
// 顶点位置 // 颜色 (RGB)
0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 顶部顶点 (红色)
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下角顶点 (绿色)
0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 右下角顶点 (蓝色)
};

为什么顶点位置在着色器中被定义为 vec4 但是代码中只有三个浮点类型?

齐次坐标:

  • 在 3D 图形学中,通常使用 齐次坐标 来表示点的坐标。齐次坐标引入了第四个分量(w),使得点可以在经过透视投影等变换时更方便地处理。
  • 齐次坐标 是一种表示方法,通常在计算机图形学中用于:
    • 处理 投影变换(如透视投影)。
    • 简化 平移、缩放、旋转 等变换操作。
  • 在齐次坐标中,一个点 (x, y, z) 会被表示为 (x, y, z, w)。通常情况下,w 的值为 1.0,表示这是一个 位置 点,而不是一个 方向(如果 w0,则表示方向向量)。

即使顶点数据的 z 之后没有显式给出,OpenGL 默认也会使用 w = 1.0,这样顶点位置就是 (x, y, z, 1.0)

理解片段着色器的插值

顶点着色器非常容易理解,每个顶点都有一个对应的颜色,片段着色器是如何着色的呢,且看一个具体的问题

如果我定义了一个三角形三个顶点,每个顶点颜色都不一样,片段着色器是怎么实现整个三角形的颜色绘制的呢?

上述三角形顶点数据中,每个顶点都有一个对应的颜色信息,这些颜色信息分别是:

  • 顶部顶点:红色 (1.0f, 0.0f, 0.0f)
  • 左下角顶点:绿色 (0.0f, 1.0f, 0.0f)
  • 右下角顶点:蓝色 (0.0f, 0.0f, 1.0f)

这些颜色值在 OpenGL 渲染过程中会根据每个片段的位置通过插值计算得出。

插值过程

OpenGL 会基于片段(像素)所在的 重心坐标 来计算插值颜色。对于每个片段,OpenGL 会基于三角形的三个顶点计算出该片段到三个顶点的加权距离,然后根据这些距离来加权三个顶点的颜色,从而得到该片段的最终颜色。

具体例子

假设你有一个三角形,顶点 A、B 和 C 分别为:

  • 顶点 A(红色):位置 (0.0f, 0.5f, 0.0f),颜色 (1.0f, 0.0f, 0.0f)
  • 顶点 B(绿色):位置 (-0.5f, -0.5f, 0.0f),颜色 (0.0f, 1.0f, 0.0f)
  • 顶点 C(蓝色):位置 (0.5f, -0.5f, 0.0f),颜色 (0.0f, 0.0f, 1.0f)

在片段着色器中,我们从顶点着色器接收到每个片段的颜色信息(即 fColor)。然后,OpenGL 会根据该片段的位置和顶点 A、B、C 的颜色进行插值。

顶点颜色的加权插值

OpenGL 通过 重心坐标 插值颜色,重心坐标是一个描述三角形内部某个点相对三个顶点的相对位置的坐标。每个片段的位置可以通过三角形的重心坐标表示为:

1
P = u * A + v * B + w * C

其中:

  • P 是该片段的最终位置。
  • uvw 是重心坐标,它们的和总是 1 (u + v + w = 1)。
  • ABC 分别是三角形的三个顶点的位置。

对于颜色的插值,同样的方式适用:

1
Color(P) = u * Color(A) + v * Color(B) + w * Color(C)

即通过重心坐标 uvw 来加权三个顶点的颜色。

举例

假设一个片段位于三角形内部,并且它的重心坐标是 u = 0.3v = 0.3w = 0.4u + v + w = 1)。那么该片段的最终颜色会是:

1
Color(P) = 0.3 * (1.0f, 0.0f, 0.0f) + 0.3 * (0.0f, 1.0f, 0.0f) + 0.4 * (0.0f, 0.0f, 1.0f)

通过计算:

1
2
Color(P) = (0.3, 0.0, 0.0) + (0.0, 0.3, 0.0) + (0.0, 0.0, 0.4)
= (0.3, 0.3, 0.4)

因此,最终这个片段的颜色将是 (0.3, 0.3, 0.4),即一个介于红色、绿色和蓝色之间的渐变颜色。

片段着色器中的实现

在片段着色器中,我们会接收从顶点着色器传递过来的颜色数据,并由 OpenGL 自动进行插值。每个片段的颜色是由其位置在三角形内的重心坐标决定的。

以下是片段着色器的代码,假设我们已经在顶点着色器中传递了颜色:

1
2
3
4
5
6
7
8
9
#version 300 es
precision mediump float;

in vec3 fColor; // 从顶点着色器传递来的颜色
out vec4 fragColor; // 输出的片段颜色

void main() {
fragColor = vec4(fColor, 1.0); // 输出颜色,设置 alpha 为 1.0
}

总结

当我们在顶点着色器中为三角形的每个顶点指定不同的颜色时,OpenGL 会自动计算每个片段的颜色。这是通过 颜色插值 完成的,即对于三角形内的每个像素,OpenGL 会根据该像素到三个顶点的距离加权计算该像素的颜色,从而实现颜色渐变效果。

向顶点着色器中传参

回过头来说说向顶点着色器中传参

我们在上面解释 layout 的时候,说到顶点着色器的参数是外部传入的,所以需要借助 layout 来进行传参

现在我们来看看具体是如何传参数的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
void TriangleSample::Draw(int screenW, int screenH) {
LOGCATE("TriangleSample::Draw");

// 定义三角形的顶点数据,包括位置和颜色
GLfloat vVertices[] = {
// 顶点位置 // 颜色 (RGB)
0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 顶部顶点 (红色)
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下角顶点 (绿色)
0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 右下角顶点 (蓝色)
};

// 检查是否已经创建了着色器程序对象
if (m_ProgramObj == 0)
return;

// 清除颜色缓冲区、深度缓冲区和模板缓冲区
glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 设置清屏背景颜色为白色
glClearColor(1.0, 1.0, 1.0, 1.0);

// 使用着色器程序
glUseProgram(m_ProgramObj);

// 加载顶点数据
// 设置顶点位置数据
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), vVertices);
glEnableVertexAttribArray(0);

// 设置顶点颜色数据
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), vVertices + 3);
glEnableVertexAttribArray(1);

// 绘制三角形
glDrawArrays(GL_TRIANGLES, 0, 3);

// 禁用着色器程序
glUseProgram(GL_NONE);
}

关键代码

1
2
3
4
5
6
7
8
9
10
/**
* @param index 目标属性位置,通常为顶点着色器中的 `layout(location = x)` 中的 x 值。
* @param size 每个属性的组件数量。例如,`3` 表示一个三维向量 (x, y, z)。
* @param type 属性的数据类型,常见的有 `GL_FLOAT`,表示浮点数。
* @param normalized 如果为 `GL_TRUE`,则会将属性值归一化到 [0,1] 或 [-1,1] 范围内;如果为 `GL_FALSE`,则属性值原样传递。
* @param stride 相邻两个属性之间的间隔,以字节为单位。通常是每个顶点的数据大小。
* @param pointer 指向顶点数据的指针,通常为一个数组或缓冲区对象的指针。此处的 `vVertices` 表示顶点数据数组的起始地址。
*/
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), vVertices);
glEnableVertexAttribArray(0);

附录

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include "TriangleSample.h"
#include "../util/GLUtils.h"
#include "../util/LogUtil.h"

TriangleSample::TriangleSample() {

}

TriangleSample::~TriangleSample() {
}

void TriangleSample::LoadImage(NativeImage *pImage) {
//null implement
}

void TriangleSample::Init() {
if (m_ProgramObj != 0)
return;
// 顶点着色器代码
char vShaderStr[] =
"#version 300 es \n" // 声明使用 OpenGL ES 3.0 版本
"layout(location = 0) in vec4 vPosition; \n" // 输入顶点位置,绑定到位置 0
"layout(location = 1) in vec3 vColor; \n" // 输入顶点颜色,绑定到位置 1
"out vec3 fColor; \n" // 输出到片段着色器的颜色变量
"void main() \n" // 主函数
"{ \n"
" gl_Position = vPosition; \n" // 将顶点位置传递给 gl_Position(OpenGL 内置变量)
" fColor = vColor; \n" // 将顶点颜色赋值给输出变量 fColor
"} \n";

// 片段着色器代码
char fShaderStr[] =
"#version 300 es \n" // 声明使用 OpenGL ES 3.0 版本
"precision mediump float; \n" // 指定默认精度为中等精度
"in vec3 fColor; \n" // 从顶点着色器接收的颜色变量
"out vec4 fragColor; \n" // 输出的片段颜色
"void main() \n" // 主函数
"{ \n"
" fragColor = vec4(fColor, 1.0); \n" // 将 fColor 赋值给 fragColor,同时指定 alpha 为 1.0
"} \n";

m_ProgramObj = GLUtils::CreateProgram(vShaderStr, fShaderStr, m_VertexShader, m_FragmentShader);
}

void TriangleSample::Draw(int screenW, int screenH) {
LOGCATE("TriangleSample::Draw");

// 定义三角形的顶点数据,包括位置和颜色
GLfloat vVertices[] = {
// 顶点位置 // 颜色 (RGB)
0.0f, 0.5f, 0.0f, 1.0f, 0.0f, 0.0f, // 顶部顶点 (红色)
-0.5f, -0.5f, 0.0f, 0.0f, 1.0f, 0.0f, // 左下角顶点 (绿色)
0.5f, -0.5f, 0.0f, 0.0f, 0.0f, 1.0f // 右下角顶点 (蓝色)
};

// 检查是否已经创建了着色器程序对象
if (m_ProgramObj == 0)
return;

// 清除颜色缓冲区、深度缓冲区和模板缓冲区
glClear(GL_STENCIL_BUFFER_BIT | GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 设置清屏背景颜色为白色
glClearColor(1.0, 1.0, 1.0, 1.0);

// 使用着色器程序
glUseProgram(m_ProgramObj);

// 加载顶点数据
// 设置顶点位置数据
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), vVertices);
glEnableVertexAttribArray(0);

// 设置顶点颜色数据
glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(GLfloat), vVertices + 3);
glEnableVertexAttribArray(1);

// 绘制三角形
glDrawArrays(GL_TRIANGLES, 0, 3);

// 禁用着色器程序
glUseProgram(GL_NONE);
}

void TriangleSample::Destroy() {
if (m_ProgramObj) {
glDeleteProgram(m_ProgramObj);
m_ProgramObj = GL_NONE;
}
}

效果

image-20250215040336594

,