Decorative image frame

【笔记】Cherno Opengl Tutorial note 02

08 How I Deal with Shaders

在之前的着色器编写中,对于每个字符串都要加引号和换行符,这是非常不方便的。

因此我们需要从文件读取shader。

一般来说会把vs和fs分成两个文件。但作者认为两个文件也很不方便,而是把vs和fs合并在一起(shaderlab就是这样做的)。并且在文件中对两部分进行区别。

在项目文件夹中新建resource文件夹,包含shaders文件夹,(以后也可能添加纹理、模型等资源),创建一个后缀为shader的文件

image-20220824221249780

将着色器的代码粘贴到文件中,并分别加上指定vs和fs编译的宏(可以用替换的功能去掉引号和换行符)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#shader vertex
#version 330 core

layout(location = 0) in vec4 position;

void main()
{
gl_Position = position;
};

#shader fragment
#version 330 core

layout(location = 0) out vec4 color;

void main()
{
color = vec4(1.0,0.0,0.0,1.0);
};

然后我们要做的是把这个文件转换成std字符串。读文件采用fstream设置文件流

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
#include <fstream>
#include <string>
#include <sstream>//stringstream

struct ShaderProgramSource{
std::string VertexSource;
std::string FrgmentSource;
};// 为了返回多重数据而制作一个结构体
static ShaderProgramSource ParseShader(const std::string& filepath) {
std::ifstream stream(filepath);

enum class ShaderType {
NONE = -1, VERTEX = 0, FRAGMENT = 1
};

std::string line;
std::stringstream ss[2];
ShaderType type = ShaderType::NONE;

while(getline(stream, line)) {//string头文件中的getline方法
if (line.find("#shader")!=std::string::npos){
//std::string::npos,表示字符串末尾(无效字符串)
if(line.find("vertex")!=std::string::npos){
// set mode to vertex
type = ShaderType::VERTEX;
} else if (line.find("fragment")!=std::string::npos) {
// set mode to fragment
type = ShaderType::FRAGMENT;
}
} else {
//把代码添加到vs或fs的字符串流中
ss[(int)type] <<line<<"\n";
}
}

return {ss[0].str(),ss[1].str()};
}

1
2
ShaderProgramSource source= ParseShader("res/shaders/Basic.shader");
//我们这里使用相对路径,可执行文件的默认工作目录是可执行文件的目录,而visual studio调试器运行,工作目录是可设置的
image-20220824224240477

将解析的着色器代码输出出来,一切正常

image-20220824224450410

那么我们实现了从一个文件里读取vs和fs,这部分修改就完成了

09 Index Buffers

  • Index Buffer是什么

我们已经画好了一个三角形。我们如果想画一个正方形呢?实际上,也是由三角形组成的。

image-20220824230232057
1
2
3
4
5
6
7
8
9
float positions[12] = {
-0.5f, -0.5f,
0.5f, -0.5f,
0.5f, 0.5f,
// 第二个三角形
0.5f, 0.5f,
-0.5f, 0.5f,
-0.5f, -0.5f
};

相应地,顶点缓冲区大小也增大了,绘制方法也需要改成6个顶点

1
glDrawArrays(GL_TRIANGLES, 0, 6);

我们成功地 绘制出了想要的正方形/长方形

image-20220824230651997

但是实际上顶点数组发生了大量重复,这也浪费了内存,尤其是顶点储存属性很多时。

因此我们使用索引缓冲Index Buffer,使顶点能够重复使用。

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
float positions[12] = {
-0.5f, -0.5f,
0.5f, -0.5f,
0.5f, 0.5f,
-0.5f, 0.5f
};//01230
unsigned int indices[] = {
0,1,2,
2,3,0
};//必须使用无符号类型

// 顶点缓冲
unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);

glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float),0);
glEnableVertexAttribArray(0);

// 制作索引缓冲区和顶点缓冲区相似
unsigned int ibo;//index buffer object
glGenBuffers(1, &ibo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

//...
//drawcall的变化
glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr);//GL_INT就出现了问题,无法渲染出画面,而index buffer是无符号的

也非常顺利

10 Dealing with Errors

这里讨论的是Opengl提供的检查错误的方法,不会用到外部工具

主要有两种方法

glGetError

使用glGetError的工作流程是首先在opengl的每个函数调用前,==在循环中调用==,直到清除所有错误。

glMessageCallBack是opengl4.3添加的功能,允许我们指定一个指向Opengl函数的指针,比glGetError更方便。这一集教程中只关注glGetError。

  • 返回值flag

image-20220825000148080

1
2
3
4
5
6
7
8
9
10
11
12
13
static void GLClearError() {
while(glGetError()!=GL_NO_ERROR);//!glGetError()
}
static void GLCheckError() {
while(GLenum error = glGetError()) {
std::cout<<"[OpenGL Error] ("<< error <<")"<<std::endl;
}
}

......
GLClearError();
glDrawElements(GL_TRIANGLES, 6, GL_INT, nullptr);//前面的符号类型错误
GLCheckError();

控制台打印出1280的错误

image-20220825000933480

在glew.h中 ,会发现各种宏的定义都是16进制的

image-20220825001114710

因此我们把1280转换成16进制,如果懒得搜的话,在断点 中定位到变量,右键可以选择16进制显示,这里结果是0x00000500,

image-20220825001334790

在glew.h中搜索,我们可以找到是GL_INVALID_ENUM的错误,也就是说,在我们检查的glDrawElements函数中,传递了一个无效的枚举类型参数。也就是GL_INT

image-20220825001532054

那么,最终的问题是,对于每个函数都做这样的处理太过麻烦,也会污染代码。所以我们最好知道错误发生在哪里 。

在这里我们已经设置了断点,当然我们在堆栈中可以找到具体的出现问题的函数。我们也可以用ASSERT来做这件事,如果条件不成立,通常发送一个消息到控制台,或是停止程序的执行 。这相当于用代码来设置断点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define ASSERT(x) if (!(x)) __debugbreak();//MSVC function
static void GLClearError() {
while(glGetError()!=GL_NO_ERROR);//!glGetError()
}
static bool GLLogCall() {
while(GLenum error = glGetError()) {
std::cout<<"[OpenGL Error] ("<< error <<")"<<std::endl;
return false;
}
return true;
}

...
GLClearError();
glDrawElements(GL_TRIANGLES, 6, GL_INT, nullptr);
ASSERT(GLLogCall());

运行后自动触发了断点

image-20220825003024764

我们还可以再设置一宏命令来简化这个过程。

1
2
3
4
5
6
#define GLCall(x) GLClearError();\
x;\
ASSERT(GLLogCall())

...
GLCALL(glDrawElements(GL_TRIANGLES, 6, GL_INT, nullptr));

非常好用。

还要解决的问题是,我们的错误消息并不能指定实际错误发生在哪个文件或行上,甚至函数名。

1
2
3
4
5
6
7
8
9
10
11
12
#define GLCall(x) GLClearError();\
x;\
ASSERT(GLLogCall(#x, __FILE__, __LINE__))//#把x转换成字符串
...
static bool GLLogCall(const char* function, const char* file, int line) {//提供函数名与文件名以及行数
while (GLenum error = glGetError()) {
std::cout << "[OpenGL Error] (" << error << "): " << function <<
" "<< file<<" : "<<line<<std::endl;
return false;
}
return true;
}

更好用了。

image-20220825004247444

接下来我们要做的就是把每个opengl方法用GLCall包装起来。

有一些情况会为GLCall添加if、while等作用域,但是这样就无法使用于赋值的构造函数语句等,因为这会让创建的变量无法被调用,因为超出了作用域(相当于封装在了一个{}scope中)

1
2
GLCall(unsigned int program = glCreaateProgram());
//这是可以使用的,但是添加作用域后不能使用,相当于把它放进了大括号

11 Uniforms

Uniform是一种从CPU获取数据的方式。

在交互时着色器变量有可能需要更新, 因此C++的变量通过Uniform传递到着色器。

我们在每次绘制前设置uniform

1
2
3
4
5
6
7
8
9
10
11
12
//Basic.shader
#shader fragment
#version 330 core

layout(location = 0) out vec4 color;

uniform vec4 u_Color;

void main()
{
color = u_Color;
};
1
2
3
4
5
6
7
8
9
//Application.cpp

glUseProgram(shader);

//创建着色器后,每个uniform都会被分配一个ID,并通过ID检索该变量的位置
int location = glGetUniformLocation(shader, "u_Color");
//如果在着色器中声明了但没有使用uniform变量,那么opengl编译时会舍弃该变量
ASSERT(location != -1);
glUniform4f(location, 0.2f, 0.3f, 0.8f, 1.0f);

通过Uniform来对变量进行变化控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int location = glGetUniformLocation(shader, "u_Color");
ASSERT(location != -1);
glUniform4f(location, 0.2f, 0.3f, 0.8f, 1.0f);

float r = 0.0f;
float increment = 0.05f;
while (!glfwWindowShouldClose(window))
{
glUniform4f(location, r, 0.3f, 0.8f, 1.0f);
GLCall(glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr));
if (r>1.0f)
increment = -0.05f;
else if (r<0.0f)
increment = 0.05f;
r += increment;
.....
}

(有可能会出现变化太快的问题,可以把双缓冲交换间隔设置为1。虽然我好像没有这个问题)

1
2
3
4
...
glfwMakeContextCurrent(window);
glfwSwapInterval(1);
...

12 Vertex Arrays

  • 绑定vertex buffer
  • 指定vertex layout
  • 绑定index buffer
1
2
3
4
//解除绑定
glUseProgram(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);

在每一帧重新绑定,结果是一样的。事实上由于每一帧绘制数据可能发生变化,正需要在这里进行绑定。

1
2
3
4
5
6
7
8
9
glUseProgram(shader);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), 0);
glEnableVertexAttribArray(0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);

glUniform4f(location, r, 0.3f, 0.8f, 1.0f);
GLCall(glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr));//前面的符号类型错误

我们需要正确使用顶点数组,为每个几何体设置不同的顶点数组对象,然后只需要在drawcall前进行绑定。这就是VAO。

绘制过程

  • 绑定着色器
  • 绑定顶点缓冲区
  • 设置顶点布局
  • 绑定index缓冲区
  • drawcall

-》使用VAO

  • 绑定着色器
  • 绑定顶点数组
  • 绑定index缓冲
  • drawcall

使用core profile

1
2
3
4
5
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);//使用opengl最高版本或最低版本为3(3.3)
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);//使用Core Profile
//可兼容性的opengl profile(compatbility Opengl profile)有VAO默认对象是0.但core profile没有默认对象,必须绑定VAO

1
2
3
4
unsigned int vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);

在这些都绑定好后,由于Opengl的状态机属性,我们可以解除绑定。然后在drawcall前再绑定vao(把vertex buffer链接到VAO),就可以正常绘制。

  • 绑定VAO
  • 绑定顶点缓冲
  • 指定顶点属性指针,这时将会把顶点缓冲和VAO绑定在一起
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
// 绑定VAO
unsigned int vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);

// 顶点缓冲
unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);

glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), 0);
glEnableVertexAttribArray(0);

// 制作索引缓冲区和顶点缓冲区相似
unsigned int ibo;//index buffer object
glGenBuffers(1, &ibo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

.....

// 解除绑定
//glUseProgram(0);
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);//反正不要在VAO激活时解绑EBO/IBO

while (!glfwWindowShouldClose(window))
{
/* Render here */

glUseProgram(shader);
glBindVertexArray(vao);
//glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
//同样重要的是要注意,索引缓冲区/ELEMENT_ARRAY_BUFFER也包含在VAO状态中。它不需要按帧重新定义...因此,您也可以删除该行。 GL_ELEMENT_ARRAY_BUFFER必须在绑定顶点数组对象(glBindVertex 数组)之后绑定。GL_ELEMENT_ARRAY_BUFFER对象存储在顶点数组对象状态向量中。 如果顶点数组对象已解绑并再次绑定,则GL_ELEMENT_ARRAY_BUFFER也已知并再次绑定。但是,如果在绑定顶点数组对象时元素数组缓冲区显式解除绑定,则会从状态向量中删除该缓冲区。


glUniform4f(location, r, 0.3f, 0.8f, 1.0f);
GLCall(glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr));//前面的符号类型错误
//.....
}

原来的做法:

每次需要绘制时,绑定顶点缓冲区,指定顶点属性指针,绑定顶点索引缓冲,然后渲染。

VAO的方法:

为每个几何对象创建VAO,绑定顶点缓冲区,指定顶点属性指针,绑定顶点索引缓冲,解绑

在渲染该对象前绑定对应的VAO

LearnOpengl对此操作的描述

1
2
3
4
5
6
7
8
9
10
11
// note that this is allowed, the call to glVertexAttribPointer registered VBO as the vertex attribute's bound vertex buffer object so afterwards we can safely unbind
glBindBuffer(GL_ARRAY_BUFFER, 0);

// remember: do NOT unbind the EBO while a VAO is active as the bound element buffer object IS stored in the VAO; keep the EBO bound.
//glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);

// You can unbind the VAO afterwards so other VAO calls won't accidentally modify this VAO, but this rarely happens. Modifying other
// VAOs requires a call to glBindVertexArray anyways so we generally don't unbind VAOs (nor VBOs) when it's not directly necessary.
glBindVertexArray(0);


在之后我们可以用class来做这件事,会方便很多。

每次绑定vertex buffer和VAO的方式哪种更快呢?

  • 实际上在过去使用一个VAO,每次绑定其他东西更快。
  • NVIDIA提出不建议使用VAO
  • 但其实在不同环境下也可能出现不同的结果
  • 如果一定特别需要压榨性能,可以看情况使用

13 Abstracting Opengl into Class

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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
#include <GL/glew.h>
#include <GLFW/glfw3.h>


#include <iostream>
#include <fstream>
#include <string>
#include <sstream>//stringstream

#define ASSERT(x) if (!(x)) __debugbreak();//MSVC function
#define GLCall(x) GLClearError();\
x;\
ASSERT(GLLogCall(#x, __FILE__, __LINE__))//#把x转换成字符串
static void GLClearError() {
while (glGetError() != GL_NO_ERROR);//!glGetError()
}
static bool GLLogCall(const char* function, const char* file, int line) {//提供函数名与文件名以及行数
while (GLenum error = glGetError()) {
std::cout << "[OpenGL Error] (" << error << "): " << function <<
" " << file << " : " << line << std::endl;
return false;
}
return true;
}


struct ShaderProgramSource {
std::string VertexSource;
std::string FrgmentSource;
};// 为了返回多重数据而制作一个结构体
static ShaderProgramSource ParseShader(const std::string& filepath) {
std::ifstream stream(filepath);

enum class ShaderType {
NONE = -1, VERTEX = 0, FRAGMENT = 1
};

std::string line;
std::stringstream ss[2];
ShaderType type = ShaderType::NONE;

while (getline(stream, line)) {//string头文件中的getline方法
if (line.find("#shader") != std::string::npos) {
//std::string::npos,表示字符串末尾(无效字符串)
if (line.find("vertex") != std::string::npos) {
// set mode to vertex
type = ShaderType::VERTEX;
}
else if (line.find("fragment") != std::string::npos) {
// set mode to fragment
type = ShaderType::FRAGMENT;
}
}
else {
//把代码添加到vs或fs的字符串流中
ss[(int)type] << line << "\n";
}
}

return { ss[0].str(),ss[1].str() };
}



static unsigned int ComplieShader(unsigned int type, const std::string& source) {
unsigned int id = glCreateShader(type);
const char* src = source.c_str();
glShaderSource(id, 1, &src, nullptr); //传递
glCompileShader(id); //编译
// TODO: Error handling
int result;
glGetShaderiv(id, GL_COMPILE_STATUS, &result);
if (!result) {
int length;
glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);//错误信息长度
char* message = (char*)alloca(length * sizeof(char));
//alloca可以在栈上动态分配
glGetShaderInfoLog(id, length, &length, message);
std::cout << "Fail to complie " << (type == GL_VERTEX_SHADER ? "vertex " : "fragment ") << "shader!" << std::endl;
std::cout << message << std::endl;
glDeleteShader(id);
return 0;
}
return id;
}

static unsigned int CreateShader(const std::string& vertexShader, const std::string& fragmentShader)
{
unsigned int program = glCreateProgram();
unsigned int vs = ComplieShader(GL_VERTEX_SHADER, vertexShader);
unsigned int fs = ComplieShader(GL_FRAGMENT_SHADER, fragmentShader);

glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program); //链接
glValidateProgram(program); //验证程序

//释放
glDeleteShader(vs);
glDeleteShader(fs);
return program;
}


int main(void)
{
GLFWwindow* window;

/* Initialize the library */
if (!glfwInit())
return -1;


glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);


/* Create a windowed mode window and its OpenGL context */
window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
if (!window)
{
glfwTerminate();
return -1;
}

/* Make the window's context current */
glfwMakeContextCurrent(window);
glfwSwapInterval(1);


if (glewInit() != GLEW_OK)
std::cout << "GlewInit fail!" << std::endl;

float positions[12] = {
-0.5f, -0.5f,
0.5f, -0.5f,
0.5f, 0.5f,
-0.5f, 0.5f
};//01230
unsigned int indices[] = {
0,1,2,
2,3,0
};//必须使用无符号类型



//绑定VAO
unsigned int vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);

// 顶点缓冲
unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);

glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), 0);
glEnableVertexAttribArray(0);

// 制作索引缓冲区和顶点缓冲区相似
unsigned int ibo;//index buffer object
glGenBuffers(1, &ibo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
/*
std::string vertexShader =
"#version 330 core\n"
"\n"
"layout(location = 0) in vec4 position;\n"
//和顶点属性的laout一致。
//注意这里是vec4,而我们实际只有vec2,剩余的部分Opengl会默认转换z0w1
"\n"
"void main()"
"{\n"
" gl_Position = position;\n"
"}\n";
std::string fragmentShader =
"#version 330 core\n"
"\n"
"layout(location = 0) out vec4 color;\n"
"\n"
"void main()"
"{\n"
" color = vec4(1.0,0.0,0.0,1.0);\n"
"}\n";
*/
ShaderProgramSource source = ParseShader("res/shaders/Basic.shader");
unsigned int shader = CreateShader(source.VertexSource, source.FrgmentSource);
//glUseProgram(shader);

//创建着色器后,每个uniform都会被分配一个ID
int location = glGetUniformLocation(shader, "u_Color");
//如果在着色器中声明了但没有使用uniform变量,那么opengl编译时会舍弃该变量
ASSERT(location != -1);
//glUniform4f(location, 0.2f, 0.3f, 0.8f, 1.0f);

float r = 0.0f;
float increment = 0.05f;

//解除绑定
//glUseProgram(0);
glBindVertexArray(0);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0);
/* Loop until the user closes the window */
while (!glfwWindowShouldClose(window))
{
/* Render here */

glUseProgram(shader);
glBindVertexArray(vao);
//glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);

glUniform4f(location, r, 0.3f, 0.8f, 1.0f);
GLCall(glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, nullptr));//前面的符号类型错误


if (r > 1.0f)
increment = -0.05f;
else if (r < 0.0f)
increment = 0.05f;
r += increment;
/* Swap front and back buffers */
glfwSwapBuffers(window);
/* Poll for and process events */
glfwPollEvents();
}
//glDeleteProgram(shader);
glfwTerminate();
return 0;
}

到这里先放一放整体的代码,然后我们将要进行大的修改。

Renderer

首先创建Renderer.h 和cpp文件。并且把错误处理的部分挪到头文件中

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
//Renderer.h#################

#pragma once

#include <GL/glew.h>

#define ASSERT(x) if (!(x)) __debugbreak();//MSVC function
#define GLCall(x) GLClearError();\
x;\
ASSERT(GLLogCall(#x, __FILE__, __LINE__))//#把x转换成字符串
void GLClearError();
bool GLLogCall(const char* function, const char* file, int line);

//Renderer.cpp###############

#include "Renderer.h"
#include <iostream>
void GLClearError() {
while (glGetError() != GL_NO_ERROR);//!glGetError()
}
bool GLLogCall(const char* function, const char* file, int line) {//提供函数名与文件名以及行数
while (GLenum error = glGetError()) {
std::cout << "[OpenGL Error] (" << error << "): " << function <<
" " << file << " : " << line << std::endl;
return false;
}
return true;
}

VertexBuffer

我们再创建VertexBuffer的头文件和cpp文件,用来定义顶点缓冲区

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//VertexBuffer.h
#pragma once

class VertexBuffer
{
private:
unsigned int m_RendererID;
// 在其他API中,也表示是一个ID,因此用这种命名来通用地表达

public:
VertexBuffer(const void* data, unsigned int size);
~VertexBuffer();

void Bind() const;
void Unbind() const;

};

我们可以右键类名后选择“快速操作和重构”,然后创建函数定义,这样IDE自动在cpp中为我们创建了各个方法的定义框架(但实际上我操作失败了,提示说所选的文本不包含任何函数签名,我只能全选下面的方法,然后再右键快速操作)

image-20220910170329326

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
//VertexBuffer.cpp
#include "VertexBuffer.h"

#include "Renderer.h"
VertexBuffer::VertexBuffer(const void* data, unsigned int size)
{
glGenBuffers(1, &m_RendererID);
glBindBuffer(GL_ARRAY_BUFFER, m_RendererID);
glBufferData(GL_ARRAY_BUFFER, size, data, GL_STATIC_DRAW);
}

VertexBuffer::~VertexBuffer()
{
glDeleteBuffers(1, &m_RendererID);
}

void VertexBuffer::Bind() const
{
glBindBuffer(GL_ARRAY_BUFFER, m_RendererID);
}

void VertexBuffer::Unbind() const
{
glBindBuffer(GL_ARRAY_BUFFER, 0);
}

IndexBuffer

对indexBuffer也可以用同样的做法.

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
//#########IndexBuffer.h##############
#pragma once

class IndexBuffer
{
private:
unsigned int m_RendererID;
unsigned int m_Count;

public:
IndexBuffer(const unsigned int* data, unsigned int count);
~IndexBuffer();

void Bind() const;
void Unbind() const;

inline unsigned int GetCount() { return m_Count; }
};
//##########IndexBuffer.cpp#############
#include "IndexBuffer.h"

#include "Renderer.h"
IndexBuffer::IndexBuffer(const unsigned int* data, unsigned int count)
: m_Count(count)
{
ASSERT(sizeof(unsigned int) == sizeof(GLuint));
//count*sizeof(GLuint)即使可能不会,但仍可能出现平台差异
glGenBuffers(1, &m_RendererID);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_RendererID);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, count * sizeof(unsigned int), data, GL_STATIC_DRAW);
}

IndexBuffer::~IndexBuffer()
{
glDeleteBuffers(1, &m_RendererID);
}

void IndexBuffer::Bind() const
{
glBindBuffer(GL_ARRAY_BUFFER, m_RendererID);
}

void IndexBuffer::Unbind() const
{
glBindBuffer(GL_ARRAY_BUFFER, 0);
}

Application

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    VertexBuffer vb(positions, 4*2*sizeof(float));

glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), 0);
glEnableVertexAttribArray(0);

IndexBuffer ib(indices, 6);

/*
// 顶点缓冲
unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);

glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), 0);
glEnableVertexAttribArray(0);

// 制作索引缓冲区和顶点缓冲区相似
unsigned int ibo;//index buffer object
glGenBuffers(1, &ibo);
glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, ibo);
glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);
*/

14 Buffer Layout Abstraction

我们希望用如下的结构来组织vao

1
2
3
4
5
6
7
8
9
VertexArray va;
VertexBuffer vb(positions, 4*2*sizeof(float));
va.AddBuffer(vb);

BufferLayout layout;
layout.Push<float>(3);
va.AddLayout(layout);
....
va.Bind();

VertexArray

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
//VertexArray.h
#pragma once
#include "VertexBuffer.h"
#include "VertexBufferLayout.h"
class VertexArray {
private:
unsigned int m_RendererID;

public:
VertexArray();
~VertexArray();

void AddBuffer(const VertexBuffer& vb, const VertexBufferLayout& layout);
void Bind() const;
void Unbind() const;
};


//VertexArray.cpp
#include "VertexArray.h"
#include "Renderer.h"
VertexArray::VertexArray()
{
glGenVertexArrays(1, &m_RendererID);

}

VertexArray::~VertexArray()
{
glDeleteVertexArrays(1, &m_RendererID);
}

void VertexArray::AddBuffer(const VertexBuffer& vb, const VertexBufferLayout& layout)
{
// 绑定VAO
Bind();
// 绑定VBO
vb.Bind();

// 顶点属性Layout
const auto& elements = layout.GetElements();

unsigned int offset = 0;
for (unsigned int i = 0; i < elements.size();i++) {
const auto& element = elements[i];
glVertexAttribPointer(i, element.count,element.type,
element.normalized, layout.GetStride(), (const void*) offset);
glEnableVertexAttribArray(i);
offset += element.count * VertexBufferElement::GetSizeOfType(element.type);
//glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float), 0);
}

}

void VertexArray::Bind() const
{
glBindVertexArray(m_RendererID);
}

void VertexArray::Unbind() const
{
glBindVertexArray(0);
}

VertexArrayLayout

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
//VertexArrayLayout.h
#pragma once
#include <vector>
#include "Renderer.h"
struct VertexBufferElement {
unsigned int type;
unsigned int count;
unsigned char normalized;

static unsigned int GetSizeOfType(unsigned int type) {
switch (type) {
case GL_FLOAT: return 4;
case GL_UNSIGNED_INT: return 4;
case GL_UNSIGNED_BYTE: return 1;
}
ASSERT(false);
return 0;
}
};
class VertexBufferLayout
{
private:
std::vector<VertexBufferElement> m_Elements;

unsigned int m_Stride;

public:
VertexBufferLayout()
: m_Stride(0) {};

template<typename T>
void Push(unsigned int count) {
static_assert(false);
}


template<>
void Push<float>(unsigned int count) {
m_Elements.push_back({ GL_FLOAT, count, GL_FALSE});
m_Stride += VertexBufferElement::GetSizeOfType(GL_FLOAT) * count;//4 * 2
}
//用模板来完成不同类型顶点属性的添加,也方便进行其他类型的扩展
template<>
void Push<unsigned int>(unsigned int count) {
m_Elements.push_back({ GL_UNSIGNED_INT, count, GL_FALSE });
m_Stride += VertexBufferElement::GetSizeOfType(GL_UNSIGNED_INT) * count;
}

template<>
void Push<unsigned char>(unsigned int count) {
m_Elements.push_back({ GL_UNSIGNED_BYTE, count, GL_TRUE });
m_Stride += VertexBufferElement::GetSizeOfType(GL_UNSIGNED_BYTE) * count;//GLbyte
}

inline const std::vector<VertexBufferElement> GetElements() const { return m_Elements; }
inline unsigned int GetStride() const { return m_Stride; }
};

作者在Bind和Unbind的地方其实处理得不是很舒服,受之前学长的影响,个人喜欢在添加完顶点属性后直接解绑,也就是AddBuffer函数之后,把va和vb解绑直接做掉。并且elementarray也就是IBO(EBO)Learnopengl中说过是绑定在VAO中的,可以不管。

15 Shader Abstraction

游戏和引擎中,通常有一种自定义着色语言,然后编译成每种api或平台适合的语言。并且是可控和可扩展的(着色器动态创建)。

我们在这里shader abstraction要完成的:

  1. 能够使用文件和字符串编译shader
  2. 绑定和取消绑定
  3. 设置uniform

Shader

shader.h

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
//Shader.h

#pragma once

#include <string>
#include <unordered_map>//hash table

struct ShaderProgramSource {
std::string VertexSource;
std::string FrgmentSource;
};// 为了返回多重数据而制作一个结构体

class Shader
{
private:
std::string m_FilePath;
unsigned int m_RendererID;


std::unordered_map<std::string, int> m_UniformLocationCache;
public:
Shader(const std::string& filepath);
~Shader();

void Bind() const;
void Unbind() const;

//set uniforms
void SetUniform4f(const std::string& name, float v0, float v1, float v2, float v3);
private:
int GetUniformLocation(const std::string& name);
unsigned int CompileShader(unsigned int type, const std::string& source);
unsigned int CreateShader(const std::string& vertexShader, const std::string& fragmentShader);
ShaderProgramSource ParseShader(const std::string& filepath);
};


Shader.cpp

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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
//Shader.cpp
#include <iostream>
#include <fstream>
#include <sstream>

#include "Renderer.h"
#include "Shader.h"
Shader::Shader(const std::string& filepath)
: m_FilePath(filepath), m_RendererID(0)
{
ShaderProgramSource source = ParseShader(filepath);
m_RendererID = CreateShader(source.VertexSource, source.FrgmentSource);

}

Shader::~Shader()
{
glDeleteProgram(m_RendererID);
}

unsigned int Shader::CompileShader(unsigned int type, const std::string& source) {
unsigned int id = glCreateShader(type);
const char* src = source.c_str();
glShaderSource(id, 1, &src, nullptr); //传递
glCompileShader(id); //编译
// TODO: Error handling
int result;
glGetShaderiv(id, GL_COMPILE_STATUS, &result);
if (!result) {
int length;
glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);//错误信息长度
char* message = (char*)alloca(length * sizeof(char));
//alloca可以在栈上动态分配
glGetShaderInfoLog(id, length, &length, message);
std::cout << "Fail to complie " << (type == GL_VERTEX_SHADER ? "vertex " : "fragment ") << "shader!" << std::endl;
std::cout << message << std::endl;
glDeleteShader(id);
return 0;
}
return id;
}

ShaderProgramSource Shader::ParseShader(const std::string& filepath) {
std::ifstream stream(filepath);

enum class ShaderType {
NONE = -1, VERTEX = 0, FRAGMENT = 1
};

std::string line;
std::stringstream ss[2];
ShaderType type = ShaderType::NONE;

while (getline(stream, line)) {//string头文件中的getline方法
if (line.find("#shader") != std::string::npos) {
//std::string::npos,表示字符串末尾(无效字符串)
if (line.find("vertex") != std::string::npos) {
// set mode to vertex
type = ShaderType::VERTEX;
}
else if (line.find("fragment") != std::string::npos) {
// set mode to fragment
type = ShaderType::FRAGMENT;
}
}
else {
//把代码添加到vs或fs的字符串流中
ss[(int)type] << line << "\n";
}
}

return { ss[0].str(),ss[1].str() };
}

unsigned int Shader::CreateShader(const std::string& vertexShader, const std::string& fragmentShader)
{
unsigned int program = glCreateProgram();
unsigned int vs = CompileShader(GL_VERTEX_SHADER, vertexShader);
unsigned int fs = CompileShader(GL_FRAGMENT_SHADER, fragmentShader);

glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program); //链接
glValidateProgram(program); //验证程序

//释放
glDeleteShader(vs);
glDeleteShader(fs);
return program;
}

void Shader::Bind() const
{

glUseProgram(m_RendererID);
}

void Shader::Unbind() const
{
glUseProgram(0);
}

void Shader::SetUniform4f(const std::string& name, float v0, float v1, float v2, float v3)
{
glUniform4f(GetUniformLocation(name), v0, v1, v2, v3);
}

int Shader::GetUniformLocation(const std::string& name)
{
//优化:我们每次setUniform的时候,都需要重新GetUniformLocation,找到属性在Layout中的位置
//这是多余的消耗,我们可以用hash table 把它储存起来。
if (m_UniformLocationCache.find(name) != m_UniformLocationCache.end()) {
return m_UniformLocationCache[name];
}

int location = glGetUniformLocation(m_RendererID, name.c_str());
if (location == -1)
std::cout << "Warning: uniform '" << name << "' dosen't exist!" << std::endl;
m_UniformLocationCache[name] = location;
return location;

}

2022年9月4日 周日

多吓人啊,暑假已经要结束了。
就在明天,就要开启人生的新阶段——去实习了。

本该在大四秋招的时候,参加了实习。怎么说呢,只能说是暑期实习无望的无奈之举。一边继续参加秋招,一边实习工作,这段经历就起不到任何作用。但其实,就我自己来说,还是挺适合的。一方面参与国产自研引擎的工作,也算是比较期待的。一方面,没有实习的工作经验,这也能做一些铺垫和学习。再者就是,如果有机会转正,也算是为秋招/春招留下一条后路。因此,也算是只赚不亏的吧。

翻了翻上次的日记,运动和练琴的计划,运动倒是好好地落实了。练琴确是到现在没碰过一次。。。。
这也是有原因的。刚做完米哈游笔试休息了几天,推进了不鸣和友塔的进度,也往前学了百人。网易互娱的测试题就开始了。。。8天的时间。这一做又是做到昨天。
中间还穿插了各种各样的事情,友塔养🐟,不鸣实习offer,雷火9月1日一天两面,9月2日体检,一天做两家笔试,昨天也是,一天两家笔试,并且搜狐畅游还是引擎岗。。。合着8月底投的引擎岗一周就进笔试了,七月底的TA到现在也是初筛。。。TA这么卷了吗?。。还是需求太少,相对比例更低。。。

忙着忙着,不知不觉地暑假就要结束了,明天也开始要上人生中的第一天班。
一个半月的时间,也做了很多事吧,但仍然有更多想做的事来不及去做。想一想今年剩下的四个月和明年的小半年时间,除了毕设,还要学一大堆东西。来不及停下歇息,被生活推着推着,就推到了人生的新阶段了。
今天摸鱼的最后一天,也不知道做什么。。。不想学也不想玩,摆烂小半天

【笔记】【百人计划】图形4.2 SSAO算法

图形4.2 SSAO算法

GAMES202 中的SSAO

https://xzyw7.github.io/post/CbZTf-uM4/#screen-space-ambient-occlusionssao

一、SSAO介绍

SSAO相关术语,简要理解及历史

  • AO
    • 环境光遮蔽Amibent Occlusion,用于模拟光线到达物体的能力的一种粗略的全局方法,描述光线到达物体表面的能力
  • SSAO
    • 屏幕空间环境光遮蔽Screen Space Ambient Occlusion,一种用于实施近似AO的渲染技术。通过获取像素的深度缓冲、法线缓冲来计算实现,近似地表现物体在间接光下产生的阴影。
  • 历史
    • AO最早在Siggraph 2002年会上有ILM(工业光魔)的技术主管Hayden Landis所展示,当时就被叫做Ambient Occlusion
    • 2007年,Crytek发布了SSAO的技术,并用在了孤岛危机上。

二、SSAO原理

简要理解SSAO算法原理

image-20220912220609664

  • 深度缓冲
    • depth用于当前视点下场景的每一个像素距离相机的粗略表达,用于重构像素相机空间中的坐标Z,来近似重构该视点下的三维场景。
  • 法线缓冲
    • 相机空间中的法线信息,用于重构每个像素的TBN坐标轴,用于计算发现半球中的采样随机向量,随机向量用于判断和描述该像素的AO强度。
  • 法向半球
    • 黑色表示我们需要计算的样本
    • 蓝色项链表示样本的法向量
    • 白色、灰色为采样点。灰色表示被遮挡采样点,据此判断最终AO的强度
    • image-20220912221417067

三、算法实现

根据原理结合UnityC#&Shader实现SSAO

实现过程环境

  • Unity2019.3.5f1
  • 透视模式
  • 前向渲染。如果为延迟渲染,则由对应的G-buffer生成,在shader中作为全局变量访问
  • 使用OnRenderImage()来处理后期,进而实现SSAO

获取深度&法线缓冲数据

  • C#部分
1
2
3
4
private void Start() {
cam = this.GetComponent<Camera>();
cam.depthTextureMode = cam.depthTextureMode | DepthTextureMode.DepthNormals;
}
  • Shader部分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//获取深度法线图
sampler2D _CameraDepthNormalsTexture;
//固定名称
...
//采样获得深度值和法线值
float3 viewNormal;
float linear01Depth;
float4 depthnormal = tex2D(_CameraDepthNormalsTexture, i.uv);
DecodeDepthNormal(depthnormal, linear01Depth, viewNormal);

//UnityCG.cginc
inline void DecodeDepthNormal(float enc, out float depth, out float3 noraml) {
depth = DecodeFloatRG(enc,zw);
normal = DecodeViewNormalStereo(enc);
}

重建相机空间坐标

  • 重建方法
    • 参考链接
    • https://zhuanlan.zhihu.com/p/92315967
    • 本例实现使用其中的“从NDC空间中重建”方法得到样本在相机空间中的向量,乘以深度值得到样本的坐标
  • 从NDC空间中重建

1.计算样本屏幕坐标

1
2
3
// 利用Unity内置函数
// 屏幕纹理坐标
float4 screenPos = ComputeScreenPos(o.vertex);

2.转化至NDC空间

1
float4 ndcPos = (screenPos/screenPos.w)*2-1;

3.计算相机空间中至远平屏幕方向(内置变量_ProjectionParams.z存放相机远平面值far)

1
float3 clipVec = float3(ndcPos.x, ndcPos.y, 1.0) * _ProjectionParams.z;

4.矩阵变换至相机空间中的样本相对相机的方向

1
o.viewVec = mul(unity_CameraInvProjection, clipVec.xyzz).xyz;

5.重建相机空间中的样本左边(在像素着色器)

1
2
float3 viewPos = linear01Depth * i.viewVec;
//在相机空间中通过样本的相对相机方向及深度来拟合重构坐标

构建法向量正交基

  1. 设置法向量
1
2
//获取像素相机屏幕法线,法线z方向相对于相机为负(ao需要乘以-1置反),并处理成单位向量
viewNormal = normalize(viewNormal) * float3(1,1,-1);
  1. 生成随机向量(用于构建的正交基随机,而非所有样本计算得到的正交基一致)(先处理成统一)
1
2
//randvec法线半球的随机向量
float3 randvec = normalize(float3(1,1,1));
  1. 求出切向量,再用函数叉积求副切线向量
1
2
3
4
5
//Cramm-Schimidt处理创建正交基
//TBN空间
float3 tangent = normalize(randvec - viewNormal * dot(randvec, viewNormal));
float3 bitangent = cross(viewNoraml, tangent);
float3x3 TBN = float3x3(tangent, bitangent, viewNormal);

AO采样

  1. 传入给定的随机采样向量,并通过法向量正交基转化至法线半球中的向量(在C#中计算出采样的随机点)
1
2
//随机向量,转化至TBN空间
float3 randomVec = mul(_SampleKernelArray[i].xyz, TBN);
  1. 获取随机坐标点
1
2
//计算随机法线半球后的向量
float3 randomPos = viewPos + randomVec * _SampleKeneralRadius;
  1. 转换至屏幕空间坐标
1
2
float3 rclipPos = mul((float3x3)unity_CameraProjection, randomPos);
float2 rscreenPos = (rclipPos.xy / rclipPos.z) * 0.5 + 0.5;
  1. 计算随机向量转化至屏幕空间后对应的深度值,并判断累加AO
1
2
3
4
5
6
7
float randomDepth;
float3 randomNormal;
float4 rcdn = tex2D(_CameraDepthNormalsTexture, rscreenPos);
DecodeDepthNormal(rcdn, randomDepth, randomNormal);

//判断累加ao
ao +=(randomDepth >= linear01Depth)? 1.0 : 0.0;

四、效果改进

效果后期改进说明

随机正交基(增加随机性)

  1. 为了不使求得的法向半球的正交基一致,我们引入随机向量。
1
2
3
4
5
//Cramm-Schimidt处理创建正交基
//TBN空间
float3 tangent = normalize(randvec - viewNormal * dot(randvec, viewNormal));
float3 bitangent = cross(viewNoraml, tangent);
float3x3 TBN = float3x3(tangent, bitangent, viewNormal);
  1. 利用uv采样一张noise贴图(如4x4像素(可选择其他尺寸)的noise贴图)或者随机向量
1
2
3
4
5
//铺平纹理
float2 noiseScale = _ScreenParams.xy / 4.0;
float2 noiseUV = i.uv * noiseScale;
//randvec法线半球的随机向量
float3 randvec = tex2D(_NoiseTex, noiseUV).xyz;

在C#中传入噪声图

image-20220912234322536

1
ssaoMaterial.SetTexture("_NoiseTex", Noise)

AO累加平滑优化

范围判定(模型边界)
  • 样本采样可能会采集到深度差非常大的随机点,导致边界出现不该有的AO

image-20220912234446963

  • 加入样本深度和随机点的深度值范围判定
1
float range = abs(randomDepth - linear01Depth) > _RangeStrength ? 0.0 : 1.0;

效果如下

image-20220912234529623

自身判定(同一深度值情况下)

如果随机点深度值和自身一样或非常接近,可能导致虽在同一平面,也会出现AO

  • 判断深度值大小的时候,增加一个Bias来改善该问题
1
float selfCheck = randomDepth + _DepthBiasValue < linear01Depth ? 1.0 : 0.0;
AO权重

AO深度判断非0即1,比较生硬,为其增加一权重

本例中权重为:法线半球中随机采样后的点x,y(切平面)距离样本的距离为参考

1
float weight = smoothstep(0, 0.2, length(randomVec.xy));
结合
1
ao += range * selfCheck * weight;
模糊(只展示效果对比)

采用基于法线的双边滤波(Bilateral Filtering)

image-20220912235055005

五、对比模型烘焙AO

同模型烘焙AO方式对比,了解SSAO优缺点

三维建模软件烘焙AO

通过DCC设定好渲染参数,对模型烘焙AO到纹理

  • 优点
    • 单一物体可控性强(通过单一物体的材质球上的AO纹理贴图),可以控制单一物体的AO强弱
    • 弥补场景烘焙的细节,整体场景的烘焙(包含AO信息),并不能完全包含单一物体细节上的AO,而通过DCC烘焙到纹理的方式,增加物体的AO细节
    • 不影响其(Unity场景中)静态或者动态
  • 缺点
    • 操作较其他方式繁琐,需要对模型进行UV处理,再烘焙到纹理
    • 不利于整体场景整合(如3Dmax烘焙到纹理只能选择单一物体,针对整体场景的处理工作量巨大)
    • 增加AO纹理贴图,不利于资源优化(后期可通过其他纹理通道利用整合资源)
    • 只有物体本身具有AO信息,获取物体之间的AO信息工作量巨大(不是不可能)

游戏引擎烘焙AO(Unity3D Lighting)

通过Unity的Lighting功能(主菜单/Window/Rendering/Lighting Settings) 进行整体场景的烘焙,AO信息包含于此

  • 优点
    • 操作简易,整体场景的烘焙,包含AO的选择
    • 不受物体本身UV的影响,unity可以通过Generate Lightmap UVs生成模型第二个纹理坐标数据
    • 可生产场景中物体与物体之间的AO信息
  • 缺点
    • 缺少单一物体的细节(可调整参数提高烘焙细节,但将增加烘焙纹理数量和尺寸以及烘焙时间)
    • 受物体是否静态影响,动态物体无法烘焙,获得AO信息

SSAO

  • 优点
    • 不依赖场景的复杂度,其效果质量依赖于最终图片像素大小
    • 实时计算,可用于动态场景
    • 可控性强,灵活性强,操作简单
  • 缺点
    • 性能消耗较上述两种方式更多,计算昂贵
    • AO质量弱于离线烘焙

六、性能消耗

主要性能消耗点

image-20220913000029828
  • AO法向半球的随机采样
  • 双边滤波的多重采样

AO核心采样消耗说明

本例SSAO中,主要核心为计算AO随机法向半球的采样点

  1. 使用For结构进行半球随机法向的采样,If,For对GPU计算性能上不友好
1
2
3
4
5
6
7
8
9
10
11
//采样核心
float ao = 0;
int sampleCount = _SampleKernelCount;//每个像素点上的采样次数
//https://blog.csdn.net/qq_39300235/article/details/102460405

for (int i=0; i<sampleCount; i++) {
//...
}
ao = ao/sampleCount;
ao = max(0.0, 1 - ao*_AOStrength);
return float4(ao,ao,ao,1);

作业

实现SSAO

使用其他算法实现进行对比

参考资料

[1] https://www.bilibili.com/video/BV16q4y1U7S3

【技术美术百人计划】图形 4.2 SSAO算法 屏幕空间环境光遮蔽

[2] https://learnopengl-cn.github.io/05%20Advanced%20Lighting/09%20SSAO/

【笔记】【百人计划】图形4.1 Bloom算法

一、Bloom算法介绍

image-20220825105103409

Bloom,也称辉光效果。模拟摄像机的一种图像效果,让物体具有真实的明亮效果。

1.1 实现思路

image-20220825105142255

  1. 提取原图较量区域
  2. 模糊该图像
  3. 与原图混合

1.2 前置知识

1.2.1 HDR与LDR

https://xzyw7.github.io/post/1_JYIZ9Hm/

如果在LDR中 使用Bloom,那么阈值提取的亮度区域不会超过1,并且光源的亮度很可能和环境中的亮度接近,导致不希望出现bloom的地方也被模糊了,因此HDR更适合使用Bloom。

image-20220825105613703

1.2.2 高斯模糊

  • 高斯模糊(Gaussian Blur)
    • 一种图像模糊
    • 减少图像噪声、降低细节层次
    • 通过高斯函数定义一个卷积核,对图像进行卷积。
image-20220825105727085
  • 计算高斯核
    • 用核的位置坐标带入高斯函数,计算结果进行归一化
image-20220825113401530
  • 二维高斯核
    • 计算量大,N*N*W*H次纹理采样
    • 可分离性:可拆成两个一维高斯核,计算次数为(N+N)*W*H
    • image-20220825113649073

二、Bloom算法应用

  • 配合自发光贴图
  • 配合特效
  • GodRay效果(基于径向模糊的后处理)
  • 配合Tone Mapping

三、Bloom算法实现

  • 思路
    • C#:调用OnRenderImage函数
    • Shader:使用4个Pass完成Bloom效果
  • 但是要做后处理的效果,我们首先使用入门精要中的PostEffectsBase作为基类。这个基类就暂且不研究了。

MonoBehaviour.OnRenderImage这个生命周期函数,用于获取RT与输出处理后的RT

这里还用到了一个重要的api,Graphics.Blit。用途是用指定的某个材质(的某个pass)来处理一个Texture,并储存到结果的RT。(pass为-1时,将依次调用所有pass)

https://docs.unity3d.com/ScriptReference/Graphics.Blit.html

其中src纹理会被传递给Shader中的_MainTex纹理属性。所以我们在脚本中看不到对_MainTex的处理,因为这是默认的

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
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Bloom : PostEffectsBase//提供了检查shder和材质的方法
{
public Shader bloomShader;
private Material bloomMaterial = null;
public Material material{
get{
bloomMaterial = CheckShaderAndCreateMaterial(bloomShader, bloomMaterial);
return bloomMaterial;
}
}

[Range(0,4)]
public int iterations = 3;//模糊迭代次数
[Range(0.2f, 3.0f)]
public float blurSpread = 0.6f;//模糊范围
[Range(1,8)]
public int downSample = 2;//下采样系数
[Range(0.0f, 4.0f)]
public float luminanceThreshold = 0.6f;
void OnRenderImage(RenderTexture src, RenderTexture dest) {
if (material != null) {
material.SetFloat("_LuminanceThreshold", luminanceThreshold);
int rtW = src.width / downSample;
int rtH = src.height / downSample;

RenderTexture buffer0 = RenderTexture.GetTemporary(rtW,rtH,0);//创建缓冲区
buffer0.filterMode = FilterMode.Bilinear;//双线性滤波
// 提取亮度区域
Graphics.Blit(src,buffer0, material, 0);//src,dest,mat,pass

for(int i=0;i<iterations;i++) {
// vertical filter Pass
material.SetFloat("_BlurSize", 1.0f+i*blurSpread);
RenderTexture buffer1 = RenderTexture.GetTemporary(rtW,rtH,0);
Graphics.Blit(buffer0,buffer1,material,1);

// horizontal filter Pass
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;//每次模糊使用的都是上一次模糊后的结果
buffer1 = RenderTexture.GetTemporary(rtW,rtH,0);
Graphics.Blit(buffer0,buffer1,material,2);

RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
}
// Blend Pass
material.SetTexture("_Bloom", buffer0);
Graphics.Blit(src,dest,material,3);
RenderTexture.ReleaseTemporary(buffer0);
} else {
Graphics.Blit(src,dest);
}
}
}

同样地,在shader里我们也要写好这4个pass。并且,这个shader是不需要创建材质然后赋给物体对象的。

材质是在脚本中创建的,并且通过Graphics.Blit这一API来调用pass对RT进行处理。

屏幕后处理实际上是在场景中绘制一个与屏幕同款同高的quad,

我们片段着色器输出pos并对x,y分别除以分辨率,就是这个结果。Z分量始终为1,x,y以左上角为坐标原点。所以Shader中我们仍然对顶点进行正常的变换处理。

image-20220825221804632
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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
Shader "Custom/Bloom"
{
Properties
{
_MainTex ("Base(RGB)", 2D) = "white" {}
_Bloom("Bloom(RGB)",2D)="black"{}
_LuminanceThreshold("Luminance Threshold",Float)=0.5
_BlurSize("Blur Size", Float) = 0.5
}
SubShader
{
CGINCLUDE
#include "UnityCG.cginc"
sampler2D _MainTex;
half4 _MainTex_TexelSize;
sampler2D _Bloom;
float _LuminanceThreshold;
float _BlurSize;


struct v2fExtractBright {
float4 pos : SV_POSITION;
half2 uv : TEXCOORD0;
};
v2fExtractBright vertExtractBright(appdata_img v) {
v2fExtractBright o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = v.texcoord;
return o;
}
fixed luminance(fixed4 color) {
return 0.2125 * color.r + 0.7154 * color.g + 0.0721 * color.b;
}

fixed4 fragExtractBright ( v2fExtractBright i) : SV_Target {
fixed4 c =tex2D(_MainTex, i.uv);
fixed val = clamp(luminance(c)-_LuminanceThreshold, 0.0 ,1.0);
return c*val;
}

struct v2fBlur {
float4 pos: SV_POSITION;
half2 uv[5] : TEXCOORD0;
};

v2fBlur vertexBlurVertical(appdata_img v) {
v2fBlur o;
o.pos = UnityObjectToClipPos(v.vertex);

half2 uv = v.texcoord;
o.uv[0] = uv;
o.uv[1] = uv + float2(0.0, _MainTex_TexelSize.y *1.0) * _BlurSize;
o.uv[2] = uv - float2(0.0, _MainTex_TexelSize.y *1.0) * _BlurSize;
o.uv[3] = uv + float2(0.0, _MainTex_TexelSize.y *2.0) * _BlurSize;
o.uv[4] = uv - float2(0.0, _MainTex_TexelSize.y *2.0) * _BlurSize;
return o;
}
v2fBlur vertexBlurHorizontal(appdata_img v) {
v2fBlur o;
o.pos = UnityObjectToClipPos(v.vertex);

half2 uv = v.texcoord;
o.uv[0] = uv;
o.uv[1] = uv + float2( _MainTex_TexelSize.x *1.0, 0.0) * _BlurSize;
o.uv[2] = uv - float2( _MainTex_TexelSize.x *1.0, 0.0) * _BlurSize;
o.uv[3] = uv + float2( _MainTex_TexelSize.x *2.0, 0.0) * _BlurSize;
o.uv[4] = uv - float2( _MainTex_TexelSize.x *2.0, 0.0) * _BlurSize;
return o;
}
fixed4 fragBlur(v2fBlur i) :SV_Target {
float weight[3] = {0.4026, 0.2442, 0.0545};
fixed3 sum = tex2D(_MainTex, i.uv[0]).rgb * weight[0];

for (int it = 1; it < 3;it++) {
sum += tex2D(_MainTex, i.uv[it*2-1]).rgb * weight[it];
sum += tex2D(_MainTex, i.uv[it*2]).rgb* weight[it];

}
return fixed4(sum, 1.0);
}
struct v2fBloom {
float4 pos : SV_POSITION;
half4 uv : TEXCOORD0;
};
v2fBloom vertBloom(appdata_img v) {
v2fBloom o;
o.pos = UnityObjectToClipPos(v.vertex)/3;
o.uv.xy = v.texcoord;
o.uv.zw = v.texcoord;
#if UNITY_UV_STARTS_AT_TOP
if (_MainTex_TexelSize.y<0.0) {
o.uv.w = 1.0-o.uv.w;
}
#endif
return o;
}

fixed4 fragBloom(v2fBloom i): SV_Target {
return tex2D(_MainTex, i.uv.xy) + tex2D(_Bloom, i.uv.zw);//fixed4(i.pos.x/1920,i.pos.y/1080,i.pos.z,1);//
}

ENDCG
ZTest Always Cull off ZWrite off
Pass {
CGPROGRAM
#pragma vertex vertExtractBright
#pragma fragment fragExtractBright
ENDCG

}
Pass {
CGPROGRAM
#pragma vertex vertexBlurVertical
#pragma fragment fragBlur
ENDCG

}
Pass {
CGPROGRAM
#pragma vertex vertexBlurHorizontal
#pragma fragment fragBlur
ENDCG

}
Pass {
CGPROGRAM
#pragma vertex vertBloom
#pragma fragment fragBloom
ENDCG

}
}
FallBack "Diffuse"
}

image-20220825212340311

模糊后

image-20220825212418349 image-20220825212653314

但是依然存在一个问题。

就是这个downSample和高斯模糊什么关系?很容易想到,用同样的核进行滤波,下采样后滤波再上采样回去,会更模糊。这就相当于用更大的核来进行滤波。

【笔记】【百人计划】图形2.7 LDR与HDR | XZYW (xzyw7.github.io)

在LDR与HDR的笔记中曾经提到过Unity的Bloom方式。Unity就是多次下采样来完成Bloom效果的,不妨把它叫做coreSize的方法

image-20220730184829163

我们也可以做这样的处理,只需要在每次迭代中增加下采样的次数就可以了。

还要做一个小小的修改就是blurSize的处理

material.SetFloat(“_BlurSize”, 1.0f);教程中为了达到这样的效果,采用了每次迭代增加BlurSize的方法,如下图( https://zhuanlan.zhihu.com/p/471923875)。

我们这里每次迭代都增加下采样,自然不用做这个处理。

img

得到的更优的结果就是,在更大的迭代数下能够减少虚影

blurSpread过大会导致严重的虚影

image-20220826001629757

到这里还有一个问题

就是其实我们会发现我们的辉光,一点都不辉,一点都不光,每次下采样后,模糊是模糊了,但是整个“光晕”也变暗了。

按照上面的说法,我们应该把每次下采样的结果叠加起来。于是我们再新建一个RT缓冲区,用来储存下采样模糊的叠加结果。

1
2
3
4
5
buffer1 = RenderTexture.GetTemporary(rtW,rtH,0);
material.SetTexture("_Bloom", buffer0);
Graphics.Blit(bufferResult1,buffer1,material,3);
RenderTexture.ReleaseTemporary(bufferResult1);
bufferResult1 = buffer1;

是理想中的辉光没错了。但是这种做法要注意控制迭代次数,因为亮度是叠加的,迭代次数太多会过曝。

image-20220826003752777

但是这样做的时候,用blurSize的结果是非常好的,但是用coreSize就出问题了。迭代次数越多,像素化越严重,以下是3次迭代,再增加就没法看了。正如入门精要中提及,尽管downSample值越大,性能越好,但过大的DownSample可能会造成图像像素化。

image-20220826010042787

的确,blurSize的方法只在最开始的时候做downSample,而coreSize的方法每一次迭代都进行下采样,是非常节省性能的,但在这种叠加的时候,就出现问题了,因为下采样的区域差别是指数级的,叠加后会形成明显的区分。

而blurSize的方法始终是在同一分辨率上做间隔采样,每次迭代只是增加了间隔数量,不会出现这种问题。

所以,为了好看的效果,还是老老实实用blurSize吧。

(不知道有没有更好的做法,能够用coreSize的方法的同时,得到均匀的Bloom)

  • URP中的Bloom与本方法的Bloom

    • image-20220826012921143
    • image-20220826013319692
  • 还有些小问题,之前加上bloom后一直出现game界面闪烁。。。就是鼠标移到game里面以后,或者移出来,画面会闪一下,运行时就很稳定。

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
RenderTexture buffer0 = RenderTexture.GetTemporary(rtW,rtH,0);//创建缓冲区
buffer0.filterMode = FilterMode.Bilinear;//双线性滤波
// 提取亮度区域
Graphics.Blit(src,buffer0, material, 0);//src,dest,mat,pass

// 新建一个RT buffer用来储存叠加结果
RenderTexture bufferResult1 = RenderTexture.GetTemporary(rtW,rtH,0);
for(int i=0;i<iterations;i++) {

// vertical filter Pass
//material.SetFloat("_BlurSize", 1.0f + i*blurSpread);
material.SetFloat("_BlurSize", 1 + i * blurSpread);
RenderTexture buffer1 = RenderTexture.GetTemporary(rtW,rtH,0);
Graphics.Blit(buffer0,buffer1,material,1);

// horizontal filter Pass
RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;//每次模糊使用的都是上一次模糊后的结果
buffer1 = RenderTexture.GetTemporary(rtW,rtH,0);
Graphics.Blit(buffer0,buffer1,material,2);

RenderTexture.ReleaseTemporary(buffer0);
buffer0 = buffer1;
//这里有个很重要的tips
//buffer1的声明周期是在for循环内,进入下一次迭代,这里的buffer1就自动销毁了,经过测试,如果我们在这里释放buffer1的内存,后面所有的图像都没了。也就是说,buffer0指向的这块内存在这里被销毁了。而如果让它自然进入下一次迭代的化,buffer1生命周期结束,但是buffer0是指向这块内存的,仍然存在(参考智能指针)。
//而且还要更复杂一些,似乎是因为GetTemporary的分配机制,他们的rt资源是重用的,gpu并没有分配新的rt。

// 换一个新的temporal RT就不会出现这个问题。
// 储存下采样结果
material.SetTexture("_Bloom", buffer0);// 这一级的下采样结果
RenderTexture bufferResult2 = RenderTexture.GetTemporary(rtW,rtH,0);// 把闲置的buffer1利用起来
Graphics.Blit(bufferResult1,bufferResult2,material,3);
RenderTexture.ReleaseTemporary(bufferResult1);
bufferResult1 = bufferResult2;
}

https://blog.csdn.net/leonwei/article/details/54972653

https://docs.unity3d.com/ScriptReference/RenderTexture.html

  • 实际上,我们的bloom的范围还应该受到亮度的影响

https://zhuanlan.zhihu.com/p/91390940发现这篇文章也做了相同的Bloom累加操作

看SSAO的开头的时候发现有同学提到catlike中也有Bloom的教程,去找了下放在这,之后可以看看这个是怎么处理这些细节的。

https://catlikecoding.com/unity/tutorials/advanced-rendering/bloom/

作业

  1. 实现Bloom效果
  2. 思考如何实现Bloom的mask功能,让区域不产生bloom效果

首先是理解这个问题,什么叫做Bloom的Mask。我的理解就是,正常的Bloom是对整张图片做后处理,那么整个画面都会受到一个全局的影响,而施加mask就是让场景中的一部分物体,虽然亮度超过阈值,但是不产生Bloom。

也就是说,在亮度提取的部分,给它一个Mask,不会提取到这部分亮度。

  • FB的Alpha通道
    • 我们可以在物体渲染的最后在alpha通道加上我们的mask参数,然后在亮度提取的时候乘上alpha通道(mask)
    • image-20220826115737712
    • 可以看到问题就是,半透明发光物体也会被影响,但控制效果是达到了。
image-20220826115659429 image-20220826115718616
  • 模板测试
    • 很自然而然的,这张mask我们可以用模板缓冲来做。
    • 就不实操了,纸上谈兵一下,渲染每个物体的时候把相关的mask写入模板缓冲,
    • 在亮度提取pass,用Equal或者其他什么操作,通过测试再提取亮度
  • 传入一张Mask
    • 可以直接public指定一个Texture2D,然后作为参数
    • 我这里随便拖了个黑白的贴图,效果还是很好的。但是实用性好像不是很大
    • image-20220826120812649

参考资料

[1] https://www.bilibili.com/video/BV1a3411z7LC

【技术美术百人计划】图形 4.1 Bloom算法 游戏中的辉光效果实现

[2] https://zhuanlan.zhihu.com/p/471923875

[3] https://kalogirou.net/2006/05/20/how-to-do-good-bloom-for-hdr-rendering/

[4] https://docs.unity3d.com/ScriptReference/Graphics.Blit.html

[5] https://blog.csdn.net/leonwei/article/details/54972653

[6] https://docs.unity3d.com/ScriptReference/RenderTexture.html

[7] https://zhuanlan.zhihu.com/p/91390940

【笔记】Cherno Opengl Tutorial note 01

01 Introduction

  • Opengl不是一个库或者api,Opengl本身是一种规范

  • 实现这种规范的是GPU厂商

  • 这种实现是在显卡驱动程序上的

  • 每个厂商对Opengl的实现都会有不同。

  • 因此Opengl不可能是开源的,这取决于编写的厂商,Nvidia,AMD

  • Opengl是跨平台的。

  • 着色器是运行在gpu上的一段程序

02 Setting up Opengl and Creating a Window in C++

thecherno.com/discord

  • 提供窗口的创建与管理——GLFW(多平台)
  • 我们当然可以下载glfw源码作为静态库在项目中编译
  • 为了方便选择预编译的二进制文件,配置环境这一部分在C++里已经做过了

这个时候直接允许文档的参考代码,

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
#include <GLFW/glfw3.h>

int main(void)
{
GLFWwindow* window;

/* Initialize the library */
if (!glfwInit())
return -1;

/* Create a windowed mode window and its OpenGL context */
window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
if (!window)
{
glfwTerminate();
return -1;
}

/* Make the window's context current */
glfwMakeContextCurrent(window);

/* Loop until the user closes the window */
while (!glfwWindowShouldClose(window))
{
/* Render here */
glClear(GL_COLOR_BUFFER_BIT);

/* Swap front and back buffers */
glfwSwapBuffers(window);

/* Poll for and process events */
glfwPollEvents();
}

glfwTerminate();
return 0;
}

可以编译成功,但是链接出现了问题

无法解析的外部符号__imp__glClear@4

image-20220818120630494

我们知道这是链接的问题,我们需要找到一个glClear函数,在一个我们没有添加的库文件中。

这个库文件就是OpenGL32.lib

https://blog.csdn.net/qq_41524721/article/details/104330656这个博客也给出了这个解决方案。)

这样我们成功地允许了文档的参考代码,画出了一个窗口

(Cherno教程当中还有一些关于平台注册的函数的连接失败,User32.lib 、Gid32.lib、Shell32.lib,我之所以没有出现这个问题,发现在项目设置的连接器命令行里已经处理过了。。。从项目默认继承也不知道咋继承出来的,但是缺少库文件,就该这样链接)

1
2
3
4
5
6
7
glBegin(GL_TRIANGLES);

glVertex2f(-0.5f,-0.5f)
glVertex2f(0.0f,0.5f)
glVertex2f(0.5f,-0.5f)

glEnd();

03 Using Modern Opengl in C++

如介绍中所说,Opengl的规范是显卡制造商实现的,因此我们需要做的是进入驱动程序,“获取”函数并调用(访问驱动程序dll文件并检索指向库中函数的指针)

理论上这是可以手动操作的,但无法跨平台。

而能够实现这些操作的库:

  • glew(opengl extention wrangler)
  • glad

教程将会使用Glew

http://glew.sourceforge.net/

我们同样只要关注include和lib文件夹(下载的压缩包里顺便有文档)

第一件事是创建一个有效的opengl渲染上下文(contex),然后使用glewInit()去初始化扩展入口点(initialize the extension entry points)

注意lib里面有两个文件

image-20220818224754923

技术上这两个链接库都是静态的,但是glew32.lib是链接到dll使用的。glew32s.lib(static)

1
2
3
4
5
6
7
#include <GLFW/glfw3.h>
#include <GL/glew.h>
...
if (!glfwInit())
return -1;
glewInit();
...

如果这样去include的话,会收获一个错误fatal error C1189: #error: gl.h included before glew.h

双击这个错误我们可以在 glew.h中看到定义

1
2
3
#if defined(__gl_h_) || defined(__GL_H__) || defined(_GL_H) || defined(__X_GL_H)
#error gl.h included before glew.h
#endif

所以我们应该把glew放到前面。

但是这部操作之后,我们发现了链接错误

error LNK2019: 无法解析的外部符号 __imp__glewInit@0,函数 _main 中引用了该符号

但是链接按理来说应该是正确的。在glew.h中搜索glewInit的定义,

image-20220818230050838

可以看到在实际返回类型前有一个GLEWAPI的宏定义

image-20220818230652054

我们没有定义GLEW_STATIC和 GLEW_BUILD,所以把GLEWAPI定义为了extern __declspec(dllimport)

这是一个 内置的msvc编译器,告诉链接器它来自于一个dll文件,所以需要dll引用,但是我们没有用dll版本的 glew。

所以我们要做的是——自己定义(其实这些文档里都有)

image-20220818230543814

这回又可以画好我们的三角形了。但是如果按照文档运行

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <GL/glew.h>
#include <GL/glut.h>
...
glutInit(&argc, argv);
glutCreateWindow("GLEW Test");
GLenum err = glewInit();
if (GLEW_OK != err)
{
/* Problem: glewInit failed, something is seriously wrong. */
fprintf(stderr, "Error: %s\n", glewGetErrorString(err));
...
}
fprintf(stdout, "Status: Using GLEW %s\n", glewGetString(GLEW_VERSION));

我们将会失败,因为glewInit是有返回值的,这个失败说明初始化并不成功。

因为我们需要先创建一个==opengl渲染上下文==。

而这个上下文就是

1
glfwMakeContextCurrent(window);

把glew初始化放在这之后就成功了。

我们还可以输出我们的 opengl版本号(这与glew无关)

1
std::cout<<glGetString(GL_VERSION)<<std::endl;

可以看到我的输出是:

4.6.0 NVIDIA 462.30

04 Vertex Buffers and Drawing a Triangle

Vertex Buffer顶点缓冲区就是一个(内存)缓冲区,实质是一个数组字节的内存。

区别是这是Opengl中的内存缓冲区,这表明它实际在我们的GPU中(显存VRAM)

当我们定义一组数据来表示三角形,将它存进GPU的VRAM中,然后我们需要调用DrawCall,让GPU从VRAM中读数据。

此外,我们还需要告诉GPU如何读取和解释数据,以及如何把它放到屏幕上。

关于渲染管线,就不赘述了。

还要记住 Opengl是一种状态机。

1
2
3
unsigned int buffer;
glGenBuffers(1, &buffer);//生成一个缓冲区,而buffer就是生成的缓冲区的id
//通常是0,1,2,3...0一般表示无效值

这就是实际对象的id,无论它储存顶点还是纹理…

那么我们就需要表示这块缓冲区如何使用 。

1
2
3
4
unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);//绑定缓冲区——表示我们如何使用它
//GL_ARRAY_BUFFER表示,这块缓冲区只是一个数组

而下一步就应该向缓冲区里传入数据。

我们可以创建缓冲区时指定大小,然后直接给出数据;或者什么都不给,用数据来更新。

我们现在为它提供绘制三角形的数据

1
2
3
4
5
6
7
8
9
float positions[6] = {
-0.5f, -0.5f,
0.0f, 0.5f,
0.5f, -0.5f
};//先准备好数据

...
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW)
//也可以6*sizeof(float),这些都是我们知道的

查文档啥都有 https://docs.gl/ ,这里包含了各种版本opengl的文档

image-20220819000352210

非常好用(刚才我们看过opengl版本了,所以是gl4)

image-20220819000549306

下面也有每个参数的含义

关于usage,是如何访问缓冲区对象的数据储存区的提示。包括访问频率(STREAM修改一次,最多使用几次,STATIC修改一次多次使用,DYNAMIC反复修改并多次使用)和访问性质(DRAW,READ,COPY)

静态和动态是最常用的。静态表示我们只创建一次缓冲区,不会每一帧都修改buffer,但是每一帧都绘制。

但要注意这只是一个Hint提示,即便声明静态,每一帧更新缓冲区仍然可以工作,只是会慢很多。

通常到这里我们还需要创建Index buffer,但是这里就先不用了

我们再来看这一串,就是在显存建立缓冲区,并指定缓冲区对象的名称(用途),最后把CPU内存的数据拿给显存

1
2
3
4
5
6
7
8
9
10
11
float positions[6] = {
-0.5f, -0.5f,
0.0f, 0.5f,
0.5f, -0.5f
};//先准备好数据

unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);

但是我们依然不知道如何使用这6个浮点数据。

我们也没有着色器来指定如何绘制 这些数据。这是之后的内容

但是如果要绘制的话,可以这样

1
2
glDrawArrays(GL_TRIANGLES,0,3);//我们没有index buffer,可以这样做
//Mode,First(starting index),Count(number of indices)

另一种方式是

1
2
3
4
5
glDrawElements(GL_TRIANGLS,3,GL_UNSIGNED_INT,indices)//和idnex buffer一起使用
//Mode
//count
//type:Must be one of GL_UNSIGNED_BYTE, GL_UNSIGNED_SHORT, or GL_UNSIGNED_INT
//indices

这就是DrawCall

注意Opengl的状态机性质,因为前面绑定了这个三角形的数据,所以drawcall就会绘制这个三角形

05 Vertex Attribute and Layouts in Opengl

总结一下,Opengl的管线工作流程就是为显卡提供数据,然后储存进GPU显存,包含了所有数据。我们会使用着色器程序在gpu上执行,去读取数据然后进行绘制。

当着色器读取数据时,顶点缓存需要知道缓存数据的布局==Layout==——这是一堆浮点数,包含每个顶点的位置、法线、纹理坐标。。。因此我们需要告诉Opengl内存的数据是如何布局的 。

顶点数据内储存了各种属性,而绑定属性的方式就是顶点属性指针

1
2
3
4
5
6
7
8
9
void glVertexAttribPointer(	GLuint	index,//属性的索引,(第几个属性,而不是位置)
GLint size, //只接受1234
//就像你不会见到float5
GLenum type,
//初始为GL_FLOAT
GLboolean normalized,

GLsizei stride,
const GLvoid * pointer);
  • noramlized

    • 规范化设置
    • 如果规范化设置为GL_TRUE ,则表示以整数格式存储的值在被访问并转换为浮点时将映射到范围 [-1,1](对于有符号值)或 [0,1](对于无符号值)。否则,值将直接转换为浮点数,而无需规范化。GL_FLOAT的设置已经规范了
  • stride

    • 连续通用顶点属性之间的字节偏移量:每个顶点之间的字节数量
    • 比如我们有一个位置3float,uv坐标2float,法线3*float 12bytes+8bytes+12bytes,stride = 32bytes
    • 可以想象Opengl从一个顶点属性跳到下一个顶点的属性,就直接根据步幅寻找
  • point

    • 指定当前绑定到目标的缓冲区的数据存储区中数组中第一个通用顶点属性的第一个组件的偏移量。。。缩句,数组的第一个属性的第一个组件的偏移量
    • 初始为0。
    • 比如位置是0,uv是12bytes(12),法线是20bytes(20)
    • C++有提供偏移类的宏
    • 注意这里需要const void*,因此需要如转换(const void*) 8

我们还需要启用这个顶点属性

1
glEnableVertexArrayAttrib(0);//index of vertex attribute
1
2
3
4
5
6
7
8
9
10
11
12
13
float positions[6] = {
-0.5f, -0.5f,
0.0f, 0.5f,
0.5f, -0.5f
};//先准备好数据

unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);

glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float),0);
glEnableVertexAttribArray(0);

以上CPU阶段的准备工作就完成了,接下来调用drawcall,就开始执行着色器程序了。

06 How a Shader Work in Opengl

实际上这个时候我们不写着色器,屏幕上已经可以画出三角形了。这是因为如果我们没有写自己的着色器的话,一些GPU会提供默认着色器。

回顾vs和fs的区别,vs对于每个顶点运行,fs对于每个像素运行。假如三角形非常巨大,那么同样的运算,vs只需要执行3次,fs则需要执行像素数的次数。这个特性可以用于一些性能优化。

其他关于shader的内容就不赘述了。

07 Writing a Shader in Opengl

我们需要定义一个新的函数,并且是静态的。因为不希望它在其他cpp文件中使用。

它用来编译我们的着色器代码。

着色器代码可以来自不同地方 ,我们可以简单地只写一个字符串。也可以从文件读取。

我们需要让Opengl编译这个程序,把vs和fs连接到一个单独的着色器程序中,并返回某种唯一的标识符,所以我们可以绑定一个着色器,然后像使用顶点缓冲那样,生成一个缓冲并返回一个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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
static unsigned int ComplieShader(unsigned int type, const std::string& source){
unsigned int id = glCreateShader(type); //在gpu中创建(绑定)
const char* src = source.c_str();//把std string转换为原生字符串;
//或const char* src = &source[0]
//我们一直在使用引用,因为要保证编译时原来的字符串始终存在。
glShaderSource(id, 1, &src, nullptr); //传递
glCompileShader(id); //编译
// TODO: Error handling
//通过glGetShaderiv来查询它是否有错误
int result;
glGetShaderiv(id, GL_COMPILE_STATUS, &result);
if (!result){//GL_FALSE = 0
int length;
glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);//错误信息长度
//char message[length];栈分配不能使用,因为length不是常量
//大多数人会使用堆分配,记得删除。但还是在栈分配上,可以如下
char* message =(char*) alloca(length * sizeof(char));
//alloca可以在栈上动态分配
glGetShaderInfoLog(id, length, &length, message);
std::cout<<"Fail to complie"<<(type == GL_VERTEX_SHADER ? "vertex" : "fragment") << "shader!"<<std::endl;
std::cout<<message<<std::endl;
glDeleteShader(id);
return 0;
}
return id;
}
//在很多地方没有使用内置变量GLuint等,是因为作者趋向使用多种图形api


static unsigned int CreateShader(const std::string& vertexShader, const std::string& fragmentShader)
{
unsigned int program = glCreateProgram();
//unsigned int vs = glCreateShader(GL_VERTEX_SHADER);
//把这一步封装到了ComplieShader里,因为所有shader都是一样的步骤,只是类型不同
unsigned int vs = ComplieShader(GL_VERTEX_SHADER,vertexShader);
unsigned int fs = ComplieShader(GL_FRAGMENT_SHADER,fragmentShader);

//这样我们就编译好了着色器,并且取得他们的id
//就像编译C++一样,对于两份文件,我们需要链接到一个程序中
glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program); //链接
glValidateProgram(program); //验证程序

//着色器已经链接到一个程序中了,我们可以删除掉了。就像我们可以删除obj中间文件
//我们的实际程序已经在program里了
//还有如glDetachShader之类的会删除源代码,但可以不用
//它们占用内存很少,并且处理图像、调试等等保留着色器源代码是有用的
glDeleteShader(vs);
glDeleteShader(fs);
return program;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
void glShaderSource(GLuint , shader
GLsizei , count
const GLchar **, string
const GLint *length);
//shader
//Specifies the handle of the shader object whose source code is to be replaced.

//count
//Specifies the number of elements in the string and length arrays.字符串的数量

//string
//Specifies an array of pointers to strings containing the source code to be loaded into the shader.
//需要提供一个双指针

//length
//Specifies an array of string lengths.
//null表示以null终止


着色器的编译和传递方法就完成了。

接下来我们编写着色器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
std::string vertexShader = 
"#version 330 core\n"
"\n"
"layout(location = 0) in vec4 position;\n"
//和顶点属性的laout一致。
//注意这里是vec4,而我们实际只有vec2,剩余的部分Opengl会默认转换z0w1
"\n"
"void main()"
"{\n"
" gl_Position = position;\n"
"}\n";
std::string fragmentShader =
"#version 330 core\n"
"\n"
"layout(location = 0) out vec4 color;\n"
"\n"
"void main()"
"{\n"
" color = vec4(1.0,0.0,0.0,1.0);\n"
"}\n";
unsigned int shader = CreateShader(vertexShader,fragmentShader);
glUseProgram(shader);

非常顺利

image-20220819124846651

再检查着色器的错误处理

image-20220819125229576

源码

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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#include <GL/glew.h>
#include <GLFW/glfw3.h>


#include <iostream>

static unsigned int ComplieShader(unsigned int type, const std::string& source) {
unsigned int id = glCreateShader(type);
const char* src = source.c_str();
glShaderSource(id, 1, &src, nullptr); //传递
glCompileShader(id); //编译
// TODO: Error handling
int result;
glGetShaderiv(id, GL_COMPILE_STATUS, &result);
if (!result) {
int length;
glGetShaderiv(id, GL_INFO_LOG_LENGTH, &length);//错误信息长度
char* message = (char*)alloca(length * sizeof(char));
//alloca可以在栈上动态分配
glGetShaderInfoLog(id, length, &length, message);
std::cout << "Fail to complie" << (type == GL_VERTEX_SHADER ? "vertex" : "fragment") << "shader!" << std::endl;
std::cout << message << std::endl;
glDeleteShader(id);
return 0;
}
return id;
}

static unsigned int CreateShader(const std::string& vertexShader, const std::string& fragmentShader)
{
unsigned int program = glCreateProgram();
unsigned int vs = ComplieShader(GL_VERTEX_SHADER, vertexShader);
unsigned int fs = ComplieShader(GL_FRAGMENT_SHADER, fragmentShader);

glAttachShader(program, vs);
glAttachShader(program, fs);
glLinkProgram(program); //链接
glValidateProgram(program); //验证程序

//释放
glDeleteShader(vs);
glDeleteShader(fs);
return program;
}


int main(void)
{
GLFWwindow* window;

/* Initialize the library */
if (!glfwInit())
return -1;

/* Create a windowed mode window and its OpenGL context */
window = glfwCreateWindow(640, 480, "Hello World", NULL, NULL);
if (!window)
{
glfwTerminate();
return -1;
}

/* Make the window's context current */
glfwMakeContextCurrent(window);

if (glewInit() != GLEW_OK)
std::cout << "GlewInit fail!" << std::endl;

float positions[6] = {
-0.5f, -0.5f,
0.0f, 0.5f,
0.5f, -0.5f
};//先准备好数据

unsigned int buffer;
glGenBuffers(1, &buffer);
glBindBuffer(GL_ARRAY_BUFFER, buffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(positions), positions, GL_STATIC_DRAW);

glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(float),0);
glEnableVertexAttribArray(0);

std::string vertexShader =
"#version 330 core\n"
"\n"
"layout(location = 0) in vec4 position;\n"
//和顶点属性的laout一致。
//注意这里是vec4,而我们实际只有vec2,剩余的部分Opengl会默认转换z0w1
"\n"
"void main()"
"{\n"
" gl_Position = position;\n"
"}\n";
std::string fragmentShader =
"#version 330 core\n"
"\n"
"layout(location = 0) out vec4 color;\n"
"\n"
"void main()"
"{\n"
" color = vec4(1.0,0.0,0.0,1.0);\n"
"}\n";
unsigned int shader = CreateShader(vertexShader, fragmentShader);
glUseProgram(shader);

/* Loop until the user closes the window */
while (!glfwWindowShouldClose(window))
{
/* Render here */
glDrawArrays(GL_TRIANGLES, 0, 3);
/* Swap front and back buffers */
glfwSwapBuffers(window);
/* Poll for and process events */
glfwPollEvents();
}
glDeleteShader(shader);//清理着色器
glfwTerminate();
return 0;
}

【笔记】【百人计划】图形3.6 纹理压缩

1. 什么是纹理压缩

纹理压缩:是为了解决内存、带宽问题,专为在计算机图形渲染系统中存储纹理而使用的图像压缩技术。

1.1 图片与纹理

  • 图片格式
    • 图片格式是图片文件的存储格式,通常在磁盘、内存中储存和传输文件时使用。
    • 例如:jpg、png、gif、bmp等
  • 纹理格式
    • 纹理格式是显卡能够直接进行采样的纹理数据格式,通常在向显卡中加载纹理时使用。
  • 纹理管线
    • 纹理压缩格式基于块压缩,能够更快读取像素所需字节块进行解压所以支持随机访问。
    • 图片压缩格式基于整张图片进行压缩,无法直接实现单个像素的解析。
    • 图片压缩格式无法被GPU识别,还需要经CPU解压缩成非压缩纹理格式才能被识别。

image-20220824164504787

2. 常见纹理压缩格式

image-20220824164711622

2.1 非压缩格式

非压缩格式
RGBA8888(RGBA32) 一个像素32位,包含A通道(一个像素消耗4字节)
RGBA4444(RGBA16) 一个像素16位,包含A通道(一个像素消耗2字节)
RGB888(RGB24) 一个像素24位,无A通道(一个像素消耗3字节)
RGB565(RGB16) 一个像素16位,无A通道(一个像素消耗2字节)

2.2 压缩格式

DXTC

DXTC纹理压缩格式来源于S3公司提出的S3TC算法,基本思想是把4x4的像素块压缩成一个64或128位的数据块,优点是创建了一个固定大小且独立的编码片段,没有共享查找表或其他依赖关系,简化了解码过程。

  • DXT1(BC1)

    • 每个块有2个16位RGB颜色值(RGB565),代表了此4x4像素块中颜色极端值,然后通过线性插值计算出两个中间颜色值,16个2位索引值则表示了每一个像素的颜色值索引;
    • (索引2个极端颜色+2个计算中间颜色)
    • 适用于不具有透明度信息的贴图(或一位(0、1)透明信息-完全不透明或完全透明)
      • 有透明信息时,只计算一个中间颜色值,剩下的一个索引表示透明信息
    • 参照RGB24的压缩率-64/(24*16) =1/6
    • image-20220824165255015
  • DXT2/3(BC2)

    • DXT2/3与DXT1类似,表示颜色信息的64位数据块不变,另外附加了64位数据来表示每个像素的Alpha信息,整个数据块变为128位
    • 每个像素占用8位,0-3表示透明信息,4-7表示颜色信息
    • 压缩率(RGBA32):128/(32*16) = 1/4
  • DXT4/5(BC3)

    • DXT4/5与DXT2/3的差异在于其Alpha信息使用了线性插值,表示颜色信息的64位数据块依然不变,Alpha信息则由2个8位Alpha极端值和16个3位索引值组成。
    • 压缩率(RGBA32):128/(32*16) = 1/4
  • 在Unity内贴图类型选为法线后会采用DXTnm压缩格式(居于DXT5),该格式会把法线贴图R通道存入A通道,然后RB通道清除为1,这样可以将法线XY信息分别存入到RGB/A中分别进行压缩,以获得更高的精度,然后再根据XY构建出Z通道数据。

ATI1/2
  • ATI1

    • 为ATI公司开发的纹理压缩格式,也被称为BC4,其每个数据块存储单个颜色的数据通道,以与DXT5中的Alpha数据相同的方式进行编码,常用于存储高度图,光滑度贴图,效果与原始图像基本无差异;
    • 压缩率(R8):64/(8*16)=1/2
  • ATI2

    • 也被称为BC5,每个块中存储两个颜色通道的数据,同上以与DX5中Alpha数据相同的方式进行编码,相当于存储了两个BC4块。
    • 压缩率(RG16):128/(8*16)=1/2
  • 如果是在将法线存储在XY双通道中采用BC5格式压缩,由于每个通道都有自己的索引,因此法线贴图XY信息可以比在BC1中保留更多的保真度,缺点是需要使用两倍内存,也需要更多带宽才能将纹理传递到着色器中;

BC6/7

仅在D3D11级图形硬件中受支持,他们每个块占用16字节,BC7针对8为RGB或RGBA数据,BC6针对RGB半精度浮点数据,因此BC6是唯一一个可以原生存储HDR的BC格式

  • BC6是专门针对HDR图像设计的压缩算法,压缩比为1/6
  • BC7是专门针对LDR图像设计的压缩算法,压缩比为1/3
    • 该格式用于高质量的RGBA压缩,可以显著减少由于压缩法线带来的错误效果
ETC

DirectX选择了DXTC作为标准压缩格式,对于Opengl则选择了爱立信研发的ETC格式,几乎所有安卓设备都可以支持ETC压缩,所以其在移动平台上被广泛使用。

ETC与DXTC具有相同的特点,将4x4的像素单元压缩成64位数据块,并将像素单元水平或竖直朝向分为两个区块,每个像素颜色等于基础颜色加上索引指向的亮度范围。

image-20220824183152849

  • ETC1

    • 每个数据块分区中的4位亮度索引信息会从16个内置亮度表中获取当前像素单元对应的亮度表,每个像素的2位像素索引值可以从亮度表的四个值中选取对应的亮度补充值
    • 压缩率(RGB24):64/(16*24)=1/6
  • ETC2

    • ETC1的扩展,支持了Alpha通道的压缩,硬件要求Opengl ES3.0和Opengl4.3以上
  • 差别

    • ETC1要求长宽为2的幂次的贴图,适用于所有安卓设备,压缩率比较高,但不适合带Alpha通道的贴图
    • ETC2要求长宽能被4整除的贴图,设备有硬件要求,支持Alpha通道压缩,内存占用大于ETC1
ASTC

ASTC是ARM和AMD联合开发的纹理压缩格式,各项指标都不错,

  • 优点
    • 可根据不同图片选择不同压缩率的算法
    • 图片不需要为2的幂次
    • 同时支持LDR和HDR
  • 缺点
    • 兼容性不够完善
    • 解码时间较长

ASTC也是基于块的压缩算法,与BC7类似,数据块大小固定为128位,不同的是块中像素数量可变,从4x4到12x12像素都有。

每个数据块中存储了两个插值端点,但不一定存储的是颜色信息,也可能是Layer信息,这样可以用来对Normal或Alpha进行更好的压缩

对于块中每个像素,存储对应插值端点的权重,存储的权重数量可以少于纹素数量,可通过插值得到每一个纹素的权重值,然后再进行颜色的计算。

  • 数据块构成
    • 11位:权重、高度信息、特殊块标识
    • 2位:Part数量
    • 4位:16种插值端点模式(如LDR/HDR,RGB/RGBA)
    • 111位:插值端点信息,纹素权重值,配置信息
PVRTC

由Imagination公司专为PowerVR显卡设计,仅支持Iphone,Ipad和部分安卓机。

不同于DXTC和ETC这类基于块的算法,PVRTC将图像分为了低频和高频信号,低频信号由两张低分辨率图像AB组成,高频信号则是低精度的调制图像,记录了每个像素混合的权重,解码时AB图像经过双线性插值放大,然后根据调制图像的权重进行混合

  • PVRTC
    • PVRTC 2-bpp(bit per pixel)
    • PVRTC 4-bpp把一个4x4的像素单元压缩成一个64位数据块,每一个块中存储一个32位(每个像素2位)的调制数据,一个1位的调制标识,15位(RGB555/RGBA4443)的颜色A,1位颜色A不透明标识(决定按照RGB还是RGBA进行存储),14位(RGB554/RGBA4433)颜色B,1位颜色B不透明标识
    • 压缩率(RGB):64/(24*16) = 1/6
    • 压缩率(RGBA):64/(32*16) = 1/8

2.3 总结

  • 画质比较
    • RGBA>ASTC 4x4>ASTC6x6>ETC2 ≈ETC1
  • 压缩比
压缩格式 压缩率
DXT1 1/6
DXT2/3 1/4
DXT4/5 1/4
ATI1 1/4
ATI2 1/4
BC6 1/6
BC7 1/3
ASTC 1/4~1/35.95
PVRTC 1/6

3. 实际应用中的选择

  • PC
    • 低质量使用DXT1,不支持A通道,使用DXT5格式支持A通道
    • 高质量使用BC7,支持A通道
  • 安卓
    • 低质量使用ETC1,不支持A通道
    • 低质量使用ETC2,支持A通道(Opengl ES3.0/Opengl 4.3以上版本)
    • 高质量使用ASTC(Android5.0/Opengl ES3.1以上版本)
  • IOS
    • 高质量使用ASTC(Iphone6以上版本)
    • 低质量使用PVRTC2(支持Iphone6以下版本)

作业

结合今天课程,针对ASTC与ETC2这两个格式进行打包测试,分析内存占用占比。

3.7的作业正好也差不多。。。放个链接

https://xzyw7.github.io/post/Lyp-tjMR_/#%E4%BD%9C%E4%B8%9A

(ASTC6x6&ETC2)最后包体的体积是非常接近的

image-20220824023612935

ASCT

image-20220824024417255

image-20220824024516591

ETC2

image-20220824024425619

image-20220824024506074

就包体而言ASCT6x6是更大一些的,但是最后的内存占用却小了一些。

(帧率的差别应该主要来源于CPU的性能差别,ASTC的CPU性能占用稍高,也就是解码的消耗)

image-20220824025009019

image-20220824025016354

再用单张贴图的压缩结果来看看各种压缩格式的效果(ASTC压缩实在是有点猛,相比也会占用更多CPU时间)

image-20220824021751226

image-20220824021823244

image-20220824021805123

image-20220824021840952

image-20220824021857439

参考资料

[1] https://www.bilibili.com/video/BV1Av411K7bt

【技术美术百人计划】图形 3.6 纹理压缩——包体瘦身术

【笔记】【百人计划】图形3.7.2 command buffer及urp概述

一、Command Buffer

Command Buffer:用来存储渲染命令的缓冲区

  • 保存渲染命令列表
    • 如set render target, draw mesh等等,可以设置在摄像机渲染期间的不同时间点执行

用途

image-20221004212010010

  • 如图使用Command Buffer获取渲染这三个物体之前的背景,输出到RT,渲染物体时采样RT进行处理,获得各种效果
  • 也可以在选择物体时使用多pass进行外扩描边(ExcuteInEditorMode的HeaderAttribute)

从Opengl指令分析Command Buffer原理

image-20221004212333393

image-20221004212443470

Unity里FrameDebugger打开的一大堆指令,也是Command Buffer

image-20221004212836046

image-20221004212938333

从自定义RenderPipeline分析(ScriptableRenderContext与Command Buffer)

这一段也许可以自己去看看catlike coding的SRP部分

image-20221004215426213

image-20221004215744770

二、Command Buffer常用函数

image-20221004215945624

  • RenderTarget

image-20221004220319374

image-20221004220610753

  • RenderTexture
    • 申请临时的RT必须手动Release
    • Release与Destroy的区别
      • https://zhuanlan.zhihu.com/p/41251356
      • Release释放显存不释放内存
      • Destroy会把Object销毁的同时连带显存释放掉,因此频繁使用Destroy会加重申请内存的负担

image-20221004220340735

image-20221004220434194

image-20221004220529916

  • DrawMesh

image-20221004223532948

三、使用方法

  • URP
  • RenderFeature与Command Buffer原理分析
  • Volume组件

四、注意事项

  • Scene窗口CameraColorTexture丢失[MSAA导致的bug ResolveAA]
  • 深度缓冲和模板缓冲[16bit和24/32bit]
  • RT精度格式问题
  • 加速优化

image-20221004225932580

作业

完成洛基中的传送门效果

参考资料

[1] https://www.bilibili.com/video/BV13F411e7Ai

【技术美术百人计划】图形 3.72 command buffer及urp概述

【笔记】【百人计划】图形3.7 移动端TB(D)R架构基础

一、当前移动端GPU概况

1.1 移动端和桌面端功耗对比

对于移动端我们通常用soc芯片名称来代指CPU

移动端GPU生产厂商主要是高通adreno,Mali和PowerVR

image-20220822235329719

1.2 移动端和桌面端带宽对比

image-20220822235406315

二、名词解释

  • System on Chip(Soc)
    • Soc是把CPU、GPU、内存、通信基带、GPS模块等等整合在一起的芯片的称呼。常见的有A系Soc(苹果),骁龙Soc(高通),麒麟Soc(华为),联发科Soc,猎户座Soc(三星),2020年苹果推出M系Soc,暂用于Mac,这说明手机、笔记本、PC的通用芯片已经出现了。
  • System Memory
    • Soc中GPU和CPU共用一块片内LPDDR物理内存,就是常说的手机内存,也叫System Memory,大概几个G。
    • 此外CPU和GPU还分别有自己的高速SRAM的Cache,也叫On-chip Memory,一般几百k-几M。不同距离的内存访问存在不同时间消耗,距离越近消耗越低,读取System Memory的时间消耗大概是On-chip Memory的几倍到几十倍
    • (soc上gpu和cpu共享一个(虚拟)内存地址空间)
  • On-Chip Memory
    • 在TB(D)R架构下会存储Tile的颜色、深度和模板缓冲,读写修改都非常快。
  • Stall停滞
    • 当一个GPU核心的两次计算结果之间有依赖关系而必须串行时,等待的过程便是Stall。
  • FillRate
    • 像素填充率 = ROP运行的时钟频率 * ROP的个数 * 每个时钟ROP可以处理的像素个数
  • TB(D)R
    • Tile-Based (Deferred) Rendering
    • 是目前主流的移动GPU渲染架构,对应一般PC上的GPU渲染架构则是IMR(Immediate Mode Rendering)
    • 指屏幕被分块(16*16或32*32像素)渲染
    • TBR:VS - Defer - Rasterize - PS
    • TBDR:VS - Defer - Rasterize - Defer - PS
    • Defer字面是延迟,但从渲染数据的角度来看,Defer就是“阻塞+批处理”GPU的“一帧”的多个数据,然后一起处理

三、立即渲染IMR

1
2
3
4
5
6
7
for draw in renderPass:
for primitive in draw:
for vertex in primitive:
execute_vertex_shader(vertex)
if primitive not culled:
for fragment in primitive:
execute_fragment_shader(fragment)

image-20220823000924957

image-20220823001016993

四、基于块元的渲染TB(D)R

TB(D)R宏观上分为2阶段

  1. 第一阶段执行所有与几何相关的处理,并生成Primitive List,并且确定每个tile上面有哪些primitive
  2. 第二阶段将逐块执行光栅化及其后续处理,并在完成后将Frame Buffer从Tile Buffer写回到System Memory中。
1
2
3
4
5
6
7
8
9
10
11
12
# Pass one
for draw in renderPass:
for primitive in draw:
for vertex in primitive:
execute_vertex_shader(vertex)
if primitive not culled:
append_tile_list(primitive)
# Pass two
for tile in renderPass:
for primitive in tile:
for fragment in primitive:
execute_fragment_shader(fragment)

image-20220823001450220

image-20220823001533374

五、TB(D)R的硬件渲染顺序

image-20220823001818885

总结

TBR的核心目的是降低带宽,减少功耗,但渲染帧率上并不比IMR块

  • 优点
    • TBR给消除Overdraw提供了机会,PowerVR用了HSR技术,Mali用了Forward Pixel Killing技术,目标一样,就是要最大限度减少被遮挡Pixel的texturing和shading。
    • TBR主要是cached friendly,在cache里读写的速度要比全局内存的速度快得多,以降低render rate的代价,降低带宽,省电
  • 缺点
    • Binning过程是在vertex阶段之后,将输出的几何数据写入到DDR,然后才被fs读取。几何数据过多的管线,容易在此处有性能瓶颈。
    • 如果某些三角形叠加在数个tile上,需要绘制数次。意味着总渲染时间将高于即时渲染模式。

六、Binning过程

Binning过程(类似四叉树)/第一个Defer

image-20220823002346662

确定哪些块元渲染哪些图元

七、不同GPU的Early-Depth-Test

第二个Defer

  • Android

    • Qualcomm Adreno采用外置模块LRZ。在正常渲染管线前,多执行一次vs生成低精度depth texture,提前剔除不可见的triangles。直接用硬件做occlusion culling,功能类似软光栅遮挡剔除/pre-Z
    • Arm Mali的FPK(Forward Pixel Killing)
      • 发生在Early-Z之后
      • image-20220823002835382
  • IOS

    • PowerVR的HSR

    • TBDR的HSR实现

      • HSR=Hidden Surface Removal
      • 对每个被投影光束交接的对象进行排序处理(使用分块减少数据集大小)
      • 只有最近的不透明和最近的透明对象需要被渲染
      • 余下的片元被剔除
    • image-20220823003102289

八、优化建议

  • 不使用FrameBuffer的时候clear或者discard
    • 主要是清空积存在tile buffer上的中间数据,所以在unity里面对render texture的使用也特别说明了一下,当不再使用这个rt之前,调用一次Discard。在Opengl ES上善用glClear,glInvalidateFrameBuffer避免不必要的Resolve(Resolve就是tile buffer刷新到system memory)行为
  • 不要在一帧里面频繁切换FrameBuffer的绑定
    • 本质上就是减少tile buffer和system memory之间的stall操作
  • 对于移动平台,建议使用alpha blend而非alpha test
    • 在实际使用中,你应该分析并比较alpha test和alpha blend的表现,因为这取决于具体内容,通常在移动平台上应避免使用alpha混合来实现透明。需要进行alpha blend时,尝试缩小混合区域的覆盖范围。
  • 手机上必须要做Alpha Test,先做一遍preZ
  • 图片尽量压缩,例如:ASTC,ETC2
  • 图片尽量走mipmap
  • 尽量使用从Vertex shader传来的Varying变量UV值采样贴图(连续的),不要在FragmentShader里动态计算贴图的UV值(非连续的),否则CacheMiss
  • 在延迟渲染尽量利用Tile Buffer
  • 如果你在unity里面调整ProjectSetting/Quality/Rendering/Texture Quality不同的设置,或者不同分辨率下,帧率有很多变化,那么多半是带宽出问题。
  • MSAA在TBDR下反而是非常快速的。
  • 少在fs中使用discard,调用gl_FragDepth从而打断Early-DT(HLSL中为Clip,GLSL中为discard)
  • 在shader里面浮点数精度,有目的区分使用float,half;
    • 带宽用量减少
    • GPU中使用的周期数减少,因为着色器编译器可以优化你的代码以提高并行化程度。
    • 要求的统一变量寄存器数量减少,这反过来又降低了寄存器数量溢出风险
  • 在移动端TB(D)R架构中,顶点处理部分容易成为瓶颈,避免使用曲面细分shader,置换贴图等负操作,提倡使用模型lod,本质上减少FrameData的压力,Unity中尽早在应用阶段借助umbra遮挡剔除。

作业

结合今天的课程,将最近做的demo继续安卓平台打包对比使用课上的优化点前后的性能变化。

这是第一次接触安卓平台的打包与性能分析。。关于性能分析使用了Unity的UPR工具。由于没有什么比较完整的demo,之前所有作业都是塞在一个项目里做的,就直接使用这个场景了,虽然感觉也很难触碰到性能瓶颈什么的。而且能够针对上面优化建议处理的点也不太多。于是选择了最直接的图片纹理压缩的部分(正好下一章节就会讲纹理压缩),应该也算非常适合了。

ASTC和ETC2两种压缩格式都非常接近

image-20220824014740272

而当我手动地取消所有纹理的压缩,改为8位浮点精度储存后,发现对性能的影响比想象中大得多。。。

image-20220824014834325

首先是apk文件的大小就增大了一些。其次是最明显的纹理资源峰值这一项,UPR还贴心地提供了说明和优化建议。而且,这里Mipmap甚至已经关掉了。(其实这里做法是相反的,应该关闭压缩开启mip,这样这两个功能在纹理和渲染效率上的作用才是协同的)

image-20220824015319329

那么在手机内存占用上,也发生了相应的变化

image-20220824020406052

image-20220824020414939

  • ReservedTotal峰值

    • image-20220824020439183
  • ReservedGFX峰值

    • image-20220824020513219

其次就是帧率也发生了下降。这当然也是由于纹理资源数据变大,计算的速度也就下降了。

image-20220824015213541

性能分析工具本身还有很多需要去研究的地方,当然这也需要项目内容的支撑。。。鉴于现在也没有什么合适的内容,这里也只能到此为止了,算是一次小小的尝试。当打包出来的项目在手机上成功渲染出画面的时候,还是非常激动的。。。

(此外,曲面细分与几何着色器生成的草地没能在手机上绘制出来,想必也是手机平台不支持这两个着色器的缘故了。)

参考资料

[1] https://www.bilibili.com/video/BV1Bb4y167zU

【技术美术百人计划】图形 3.7 移动端TB(D)R架构基础

[2] https://blog.imaginationtech.com/a-look-at-the-powervr-graphics-architecture-tile-based-rendering/

[3] https://upr.unity.cn/instructions/desktop

【笔记】【百人计划】图形3.5 Early-z和Z-prepass(preZ)

一、深度测试

  • fs-Alpha Test-Stencil Test-==Depth Test==

  • 解决物体可见遮挡性的问题

image-20220822155007188

因为测试阶段发生在fs之后,片元即便被丢弃,已经经过了fs中大量的无用的计算。

二、提前深度测试Early-Z

  • 解决过多不必要的片元计算问题
  • 光栅化-==EarlyZ==-fs-Alpha Test-Stencil Test-==Depth Test==
image-20220822155402937
  • EarlyZ也可以基于模板测试在着色前丢弃片元
  • EarlyZ剔除的最小单位不是1像素,而是像素块(2x2)

2.1 EarlyZ失效

通常在EarlyZ阶段不仅会进行深度测试,还会写入深度

  1. 开启ALpha Test或Clip/discard等手动丢弃片元操作
    • 如果手动丢弃片元,导致如果前面的片元丢弃了,深度仍然存在,后续的像素也会被丢弃。
  2. 手动修改GPU插值得到的深度
  3. 开启Alpha Blend
    • 开启Alpha Blend的物体一般不会开启深度写入ZWrite off
  4. 关闭深度测试Depth Test
  5. 开启Multi-Sampling:多重采样会影响周边像素,而EarlyZ无法得知周边像素是否被裁剪,无法提前剔除
  6. 以及其他任何导致需要混合后面颜色的操作

此外,Early-Z技术会导致**深度数据冲突(depth data hazard)**的问题

img

避免深度数据冲突的方法之一是在写入深度值之前,再次与frame buffer的值进行对比:

1617944-20190906001545523-562795391.png

2.2 高效利用EarlyZ

将不透明物体由近向远渲染,EarlyZ的优化效果最好

  • CPU进行深度排序
  • 场景复杂时,排序消耗增大。
  • 严格按照从近到远渲染,不能同时搭配合批优化手段
image-20220822155921813

三、使用Z-Prepass

  1. 在第一个pass即Z-Prepass中只写入深度,不计算输出任何颜色
    • 自动计算了最小深度的zbuffer,无需cpu排序
    • EarlyZ阶段也可以提升一点效率,虽然本来就没什么计算
  2. 第二个pass关闭深度写入,并且将深度比较函数设置为相等
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
SubShader {
Tags{"RenderType" = "Opaque"}
//PreZ
Pass {
ZWrite On // 开启深度写入
ColorMask 0 // 关闭颜色输出
CGROGRAM
...//省略顶点变换部分
ENDCG
}
//正常地计算输出颜色
Pass {
ZWrite Off // 关闭深度写入
ZTest Equal // 深度相等通过
CGPROGRAM
...
ENDCG
}
}

四、Z-Prepass带来的问题

4.1 动态批处理问题

多pass的shader无法进行动态批处理-增加drawcall

解决方法:提前分离的PrePass

仍然使用两个pass

  1. 将原先第一个pass(Z-Prepass)单独分离出来为单独一个shader,并使用这个shader将整个场景的Opaque物体渲染一遍(写入深度)
  2. 原先材质只剩下原先的第二个Pass,仍关闭深度写入,并且深度比较函数设置为相等

两个pass都能够分别被批处理

4.2 Z-Prepass解决透明渲染

不写入深度的透明渲染会出现深度穿插错误的问题

image-20220822161301143(1)

(无法看到透明物体的背面)

  • 如果要用Z-Prepass同时渲染物体背面
    • 需要先渲染背面,剔除正面
    • 在下一个pass渲染正面,剔除背面
    • 可用于头发渲染

4.3 其他问题

计算消耗

image-20220822161643680

权衡片元计算复杂性/overdraw与Z-Prepass的消耗,根据实际情况采用

五、Early-Z与Z-Prepass的实例应用

https://www.cnblogs.com/jaffhan/p/7382106.html

image-20220822161834083 image-20220822161855886

作业

  1. 做下preZ的效果测试

左边的狮子是preZ,右边的狮子是普通的Alpha。

image-20220823202352241

因为是透明渲染,所以自然而然会想到preZ的方法能否应用到之前深度测试的透视效果。

因为preZ记录了最前面的一个深度。透视,也就是说透明物体前面被遮挡住了,并且已经绘制了这个遮挡物体,那么就是遮挡物体的深度写入了深度缓冲。我们也就没有了被遮挡物体最近面的深度了。而之后要绘制出来,也要用Greater的判定方式,最终透明物体无法表现出自遮挡。

并且如果没有遮挡物的时候,物体自身最近片元就写入了深度。那么物体自己对自己形成遮挡,最后会发生没有遮挡时,绘制出了物体自身重叠部分。这种没有对透视效果做特殊处理,所以最后一次绘制物体自身的时候会覆盖(这是深度缓冲依然是物体自身最近距离,测试规则为LEqual)但是如果透视部分有特殊效果,那么正常的物体渲染可能会漏出这一部分。

总之结论就是,这个效果同一物体多pass的实现方法,无法用preZ来完成。

image-20220823203258886(1)

  1. 总结earlyZ的限制
  • EarlyZ-失效

    • 开启ALpha Test或Clip/discard等手动丢弃片元操作
      • 如果手动丢弃片元,导致如果前面的片元丢弃了,深度仍然存在,后续的像素也会被丢弃。
    • 手动修改GPU插值得到的深度
    • 开启Alpha Blend
      • 开启Alpha Blend的物体一般不会开启深度写入ZWrite off
    • 关闭深度测试Depth Test
    • 开启Multi-Sampling:多重采样会影响周边像素,而EarlyZ无法得知周边像素是否被裁剪,无法提前剔除
    • 以及其他任何导致需要混合后面颜色的操作
  • 导致深度数据冲突

  • 为了最大利用EarlyZ按照从近到远顺序渲染,但CPU阶段排序耗时,且严格按照从近到远渲染不能同时搭配合批优化手段

参考资料

[1] https://www.bilibili.com/video/BV1FK4y1u7iw

【技术美术百人计划】图形 3.5 Early-z和Z-prepass

[2] https://www.bilibili.com/video/BV1aM4y1g75f

【技术美术百人计划】图形 2.7.2 GPU硬件架构概述

[3] https://www.cnblogs.com/timlly/p/11471507.html

【笔记】【百人计划】图形3.4 延迟渲染管线介绍

1.渲染路径

渲染路径(Rendering Path):决定光照的实现方式。就是当前渲染目标使用光照的流程

2.渲染方式

2.1 前向渲染Forward Rendering

image-20220820011602900

在渲染每一帧时,每个顶点、片元都要执行一次着色器代码。这时需要将所有的光照信息都传递到片元着色器中。虽然大部分情况下的光源都趋向于小型化,而其照亮的区域也不大,但即便是光源离这个像素所对应的世界空间中的位置很远,计算光照时还是会把所有光源考虑进去。复杂度是$O(mn)$ 。

1
2
3
For each light:
For each object affected by the light:
framebuffer += object * light

2.2 延迟渲染Deferred Rendering

image-20220820012923949

主要解决大量光照渲染的方案。先不要迭代三角形做光照计算,而是先找出能看到的所有像素,再去迭代光照。直接迭代三角形的话,由于大量三角形不可见,是浪费的。

如前向渲染下,近处光源和远处光源计算量相等。但延迟渲染下,近处的计算量会大于远处。因为远处的像素更少。

image-20220820012418691

Gbuffer

img

流程:

分成两个pass

  • 几何处理pass
    • 首先将场景渲染一次,获取待渲染对象的各种几何信息,储存到G-buffer。并且开启深度测试,所以G-buffer只写入最近的片元的信息。
  • 光照处理pass
    • 遍历所有G-buffer的各种参数,执行光照计算。
1
2
3
4
For each object:
Render to multiple targets
For each light:
Apply light as a 2D postprocess

复杂度是$O(m+n)$ 。

问题:如何渲染透明物体

透明物体在前向渲染中,一般最后渲染,并且不写入深度。

所以在延迟渲染中,还是需要用前向渲染的方式,最后渲染透明物体

2.3 不同渲染路径的优劣及特性

2.3.1 区别
  • 后处理方式不同

    • 假设需要深度信息来进行后处理,前向渲染就需要单独渲染深度图
  • 着色计算不同

    • 延迟渲染只能计算一个光照模型,因为是统一在LightPass计算的
  • 抗锯齿方式不同

2.3.2 优劣
优点 缺点
前向渲染 1. 支持半透明渲染 1. 光源数量对计算复杂度影响巨大
2. 支持使用多个光照pass 2. 访问深度等数据需要额外计算
3. 支持自定义光照计算方式
延迟渲染 1. 大量光照场景优势明显 1. 对MSAA支持不友好
2. 只渲染可见像素,节省计算量 2. 透明物体渲染存在问题
3. 对后处理支持良好 3. 占用大量的显存带宽
4. 用更少的shader

3.其他

3.1 渲染路径的设置方式

(2021.2版本以前,URP不支持延迟渲染)

  1. 项目设置Tier Settings 的Rendering Path改为延迟渲染,并勾选Use Defaults
  2. 相机的Rendering Path使用延迟渲染

image-20220820014419975

3.2 移动端优化
  • 两个TBDR
    • 一个是SIGGRAPH2010提出,通过分块来降低带宽内存用量
    • 一个是PowerVR基于手机GPU的TBR架构提出的,通过HSR减少overdraw

image-20220820014624273

3.3 其他渲染路径

https://zhuanlan.zhihu.com/p/54694743

  • 延迟光照 Light Pre-Pass/Deferred Lighting
    • 减少G-buffer占用的过多开销,支持多种光照模型
  • Forward+ 即Tiled Forward Rendering,分块正向渲染
    • 减少带宽,支持多光源,强制需要一个preZ
  • 群组渲染Clustered Rendering
    • 带宽相对减少,多光源下效率提升

作业

  1. 总结延迟渲染管线的优缺点
    • 文内
  2. 如何优化(移动端优化技术)
    • 3.7见

参考文献

[1] https://www.bilibili.com/video/BV1244y1i7oV

【技术美术百人计划】图形 3.4 延迟渲染管线介绍

[2] https://learnopengl-cn.github.io/05%20Advanced%20Lighting/08%20Deferred%20Shading/