从0开始的OpenGL学习(三十六)-Debugging
说到编程,写代码,有一个我们永远绕不过去的话题就是Debug。BUG这种东西真是对它恨之入骨啊,不经意间的一个BUG就可以毁掉你的夜晚,甚至毁掉你的周末。每次听到有BUG的时候,心里总是会感觉不爽,这种不爽,既包含了对自己无能的愤怒,也包含了对测试人员胡乱操作的愤怒。但是,不管怎么说,对BUG我们只能控制,无法彻底消灭,在编程这条路上,我们正和BUG同行,并且会永远同行下去。
感慨完了,言归正传。我们不喜欢BUG,遇到一个就要消灭一个。消灭BUG最难的地方就是找到产生BUG的原因(找找2小时,修修5分钟),在游戏编程中更是如此。在本文中,我们会先讨论一些如何查找OpenGL状态BUG的方法,然后是不需要运行程序前提下检查Shader语法是否正确的方法,最后讨论一些查找逻辑BUG(也是最难发现的BUG)的一些方法。在查找逻辑BUG的时候,我们可以自己输出一些中间数据,也可以使用3方工具来捕获一帧的数据进行分析。
查找不正确的使用OpenGL导致的问题
glGetError()函数
如果是在设置OpenGL的状态时出的问题(OpenGL的状态太复杂了,出问题也正常,不要有压力),我们有一个非常好的工具来捕捉这个错误,那就是OpenGL自带的glGetError()函数。这个函数会捕捉最近的一次错误,然后以ID的形式返回。该函数可以捕捉到的错误信息如下所示:
Flag | Code | Description |
---|---|---|
GL_NO_ERROR | 0 | 没有错误 |
GL_INVALID_ENUM | 1280 | 非法枚举 |
GL_INVALID_VALUE | 1281 | 非法值 |
GL_INVALID_OPERATION | 1282 | 非法操作 |
GL_STACK_OVERFLOW | 1283 | 堆栈溢出 |
GL_STACK_UNDERFLOW | 1284 | 堆栈下溢 |
GL_OUT_OF_MEMORY | 1285 | 内存不足 |
GL_INVALID_FRAMEBUFFER_OPERATION | 1286 | 强行读写未完成的帧缓存 |
要注意的是,这个函数有一个很大的缺点,那就是:会把错误状态给重置。也就是说,如果你连续调用glGetError()函数两次,第二次调用的返回值必然是0(表示没有任何错误)。
我们故意写点错误代码来测试一下,比如说,直接调用glGetError(),此时没有错误发生,函数返回值应该是0:
...
glGetError();
再比如说,在使用glGenTextures函数时,第一个参数传递一个负数(例如-5),glGetError()应该会返回一个1281的错误:
unsigned int tex;
glGenTextures(-5, &tex);
std::cout << glGetError() << std::endl; //返回1281
再再比如说,再调用glTexImage2D函数是,第一个参数传递GL_TEXTURE_3D,glGetError()应该返回一个1280的错误
glm::vec3 data1[16];
glTexImage2D(GL_TEXTURE_3D, 0, GL_RGB, 512, 512, 0, GL_RGB, GL_UNSIGNED_BYTE, data1);
std::cout << glGetError() << std::endl; //返回1280
运行上面的这些代码,我们得到了预料之中的结果:
运行结果
为了更清晰的输出错误信息,同时也为了方便使用,我们再对glGetError()函数做一次封装,封装之后的函数要有glGetError函数的功能,同时也要提供有意义的错误信息(用字符串说明错误原因),我们将这个函数单独放在一个头文件中,起名error.h:
...
GLenum glCheckError_(const char* file, int line) {
GLenum errorCode;
while ((errorCode = glGetError()) != GL_NO_ERROR) {
std::string error;
switch (errorCode) {
case GL_INVALID_ENUM:
error = "INVALID_ENUM";
break;
case GL_INVALID_VALUE:
error = "INVALID_VALUE";
break;
case GL_INVALID_OPERATION:
error = "INVALID_OPERATION";
break;
//case GL_STACK_OVERFLOW:
// error = "STACK_OVERFLOW";
// break;
//case GL_STACK_UNDERFLOW:
// error = "STACK_UNDERFLOOR";
// break;
case GL_OUT_OF_MEMORY:
error = "OUT_OF_MEMORY";
break;
case GL_INVALID_FRAMEBUFFER_OPERATION:
error = "INVALID_FRAMEBUFFER_OPERATION";
break;
}
std::cout << error << " | " << file << " (" << line << ") " << std::endl;
}
return errorCode;
}
#define glCheckError() glCheckError_(__FILE__, __LINE__)
代码中不容易理解的地方大概只有一处,就是FILE,LINE这两个宏。这是两个内置宏,表示调用此函数的文件以及在文件中的行数,直接输出这两项信息可以大大提高我们定位错误的效率,非常有用。
用glCheckError()宏替换上面的std::cout << glGetError() << std::endl;
一行,我们就能得到更直观的错误信息:
如果glGetError()的返回值是0,表示没有错误,我们不感兴趣,只在有错误的时候输出信息就好了。
glDebugOutput()函数
还有一个使用范围不如glGetError()广,但作用更大函数glDebugOutput()。它所诊断的“病因”更加详细,比如,下面就是这个函数诊断出的“病因”:
运行效果
它告诉了我们出错的原因:GL_INVALID_VALUE 错误产生。以及改正的方法:参数不能是负数。不仅如此,glDebugOutput还会检测是什么代码除了问题(上图中的Source。API表示OpenGL API出错),错误类型(上图中的Type。Error表示错误),以及严重程度(上图中的Severity。high表示高级)。通过这些信息,我们就可以很容易的解决问题了。
和glGetError()函数不同,glDebugOutput函数是一个自定义的回调函数(这意味着你可以使用任意函数名,只要声明的形式和下面代码中的一样就行了),我们也把它放到error.h文件中:
void APIENTRY glDebugOutput(GLenum source, GLenum type, GLuint id,
GLenum severity, GLsizei length, const GLchar* message, const void* userParam) {
// 忽略一些不是错误的id
if (id == 131169 || id == 131185 || id == 131218 || id == 131204) return;
std::cout << "---------------------------" << std::endl;
std::cout << "调试信息(" << id << "):" << message << std::endl;
switch (source) {
case GL_DEBUG_SOURCE_API:
std::cout << "Source: API";
break;
case GL_DEBUG_SOURCE_WINDOW_SYSTEM:
std::cout << "Source: Window System";
break;
case GL_DEBUG_SOURCE_SHADER_COMPILER:
std::cout << "Source: Shader Compiler";
break;
case GL_DEBUG_SOURCE_THIRD_PARTY:
std::cout << "Source: Third Party";
break;
case GL_DEBUG_SOURCE_APPLICATION:
std::cout << "Source: APPLICATION";
break;
case GL_DEBUG_SOURCE_OTHER:
break;
}
std::cout << std::endl;
switch (type) {
case GL_DEBUG_TYPE_ERROR:
std::cout << "Type: Error";
break;
case GL_DEBUG_TYPE_DEPRECATED_BEHAVIOR:
std::cout << "Type: Deprecated Behaviour";
break;
case GL_DEBUG_TYPE_UNDEFINED_BEHAVIOR:
std::cout << "Type: Undefined Behaviour";
break;
case GL_DEBUG_TYPE_PORTABILITY:
std::cout << "Type: Portability";
break;
case GL_DEBUG_TYPE_PERFORMANCE:
std::cout << "Type: Performance";
break;
case GL_DEBUG_TYPE_MARKER:
std::cout << "Type: Marker";
break;
case GL_DEBUG_TYPE_PUSH_GROUP:
std::cout << "Type: Push Group";
break;
case GL_DEBUG_TYPE_POP_GROUP:
std::cout << "Type: Pop Group";
break;
case GL_DEBUG_TYPE_OTHER:
std::cout << "Type: Other";
break;
}
std::cout << std::endl;
switch (severity) {
case GL_DEBUG_SEVERITY_HIGH:
std::cout << "Severity: high";
break;
case GL_DEBUG_SEVERITY_MEDIUM:
std::cout << "Severity: medium";
break;
case GL_DEBUG_SEVERITY_LOW:
std::cout << "Severity: low";
break;
case GL_DEBUG_SEVERITY_NOTIFICATION:
std::cout << "Severity: notification";
break;
}
std::cout << std::endl;
std::cout << std::endl;
}
函数的开头,我们就过滤了一些错误ID(比如131185 在N卡驱动中表示成功创建缓存)。然后,根据参数,详细的输出了错误。可以看到,这个错误信息非常详细,远不是glGetError()函数所能比的。
完成封装之后,接下来就是如何使用了。有一个坏消息是,glDebugOutput方法只被OpenGL 4.3及以上版本支持,这就意味着你需要返回第一篇文章去下载4.3版本的glad文件。将文件放到合适的位置后,调用下面这行代码启用回调:
glfwWindowHint(GLFW_OPENGL_DEBUG_CONTEXT, GL_TRUE);
完成设置之后,我们要检查一下是否成功开启调试输出功能。检查的方法是调用glGetIntegerv()函数,请看这里:
GLint flags;
glGetIntegerv(GL_CONTEXT_FLAGS, &flags);
if (flags & GL_CONTEXT_FLAG_DEBUG_BIT) {
std::cout << "启用调试上下文成功" << std::endl;
glEnable(GL_DEBUG_OUTPUT);
glEnable(GL_DEBUG_OUTPUT_SYNCHRONOUS);
glDebugMessageCallback(glDebugOutput, nullptr);
glDebugMessageControl(GL_DONT_CARE, GL_DONT_CARE, GL_DONT_CARE, 0, nullptr, GL_TRUE);
}
glDebugMessageCallback函数设置了回调函数为glDebugOutput,glDebugMessageControl设置了捕捉哪些错误(GL_DONT_CARE表示所有错误都捕捉)。完成设置之后,运行上述代码,你将看到类似本节开头的错误信息。
除了捕捉错误之外,我们还可以手动插入错误,比如说glDebugMessageInsert(GL_DEBUG_SOURCE_APPLICATION,GL_DEBUG_TYPE_ERROR, 0, GL_DEBUG_SEVERITY_MEDIUM, -1, "error message here"); 这行代码就插入了一条自定义的错误信息,它也会被我们的回调函数捕捉到。更加说明了调试输出是一个非常有用的功能(虽然会吃资源)!
Shader语法检查
每次都要运行程序才发现着色器有语法错误是不是很烦恼?如果有个编译器该多好?别急,编译器是没有,但是有检查语法的工具,不过只是命令行工具。
想要获取编译器,你可以到其官方提供的下载接口去下载,也可以直接到我的百度云上去下载。只是一个1M多的exe文件,非常小巧。下载完成后,将其复制到着色器文件所在目录,使用命令行打开该目录,像这样:
后缀名 | 着色器类型 |
---|---|
.vert | 顶点着色器 |
.frag | 片元着色器 |
.geom | 几何着色器 |
.tesc | 细分控制着色器 |
.tese | 细分评估着色器 |
.comp | 计算着色器 |
在命令行中输入glslangValidator.exe shader.vert即可检查shader的语法。要注意,顶点着色器的后缀名必须是.vert,否则无法识别。下面的表列举了支持的后缀名及着色器类型:
后缀名 | 着色器类型 |
---|---|
.vert | 顶点着色器 |
.frag | 片元着色器 |
.geom | 几何着色器 |
.tesc | 细分控制着色器 |
.tese | 细分评估着色器 |
.comp | 计算着色器 |
我终于知道为什么那么多的着色器都是用.vert和.frag的后缀了,原来根子在这。如果着色器没有语法上的错误,编译器就不会有什么输出,如果有错误,就会报错:
运行效果
很给力的给出了哪一行出错了,让我们能直接定位,很快修改。
查找逻辑BUG
查语法错误容易,查逻辑错误就难了。直观的,如果将“中间阶段”的数据输出,是不是就能提前发现问题呢?这是个不错的想法,我们这就来实现它。
要显示帧缓存很容易(假设你已经有帧缓存了),直接把帧缓存的颜色缓存当成一张纹理图渲染出来就可以了。顶点着色器中不需要(一定不能要)进行什么位置变换,片元着色器中也只需要对输入的纹理进行采样,然后输出就可以了。
//顶点着色器
#version 330 core
layout (location = 0) in vec2 position;
layout (location = 1) in vec2 texCoords;
out vec2 TexCoords;
void main()
{
gl_Position = vec4(position, 0.0f, 1.0f);
TexCoords = texCoords;
}
//片元着色器
#version 330 core
out vec4 FragColor;
in vec2 TexCoords;
uniform sampler2D fboAttachment;
void main()
{
FragColor = texture(fboAttachment, TexCoords);
}
准备好着色器之后,我们再来准备一个绘制函数。和之前的RenderQuad函数类似,只是多了一个纹理ID,在绘制的时候绑定输入的纹理ID就可以了。完整的代码如下所示:
bool notInitialized = false;
unsigned int quadVAO = 0;
unsigned int quadVBO = 0;
void DisplayFramebufferTexture(GLuint textureID) {
if (!notInitialized) {
float quadVertices[] = {
// 位置 // 纹理
0.5f, 1.0f, 0.0f, 0.0f, 1.0f,
0.5f, 0.5f, 0.0f, 0.0f, 0.0f,
1.0f, 1.0f, 0.0f, 1.0f, 1.0f,
1.0f, 0.5f, 0.0f, 1.0f, 0.0f,
};
// 设置平面VAO
glGenVertexArrays(1, &quadVAO);
glGenBuffers(1, &quadVBO);
glBindVertexArray(quadVAO);
glBindBuffer(GL_ARRAY_BUFFER, quadVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(quadVertices), &quadVertices, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
}
glBindVertexArray(quadVAO);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, textureID);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
glBindVertexArray(0);
}
完成之后,在合适的地方调用此函数,我们就能得到如下的输出:
显示效果
非常棒,一边看最终的显示效果,一遍看中间状态,有什么问题都能一目了然。完整的代码请到参考这里。
RenderDoc
最后,再介绍一个非常强大的工具:RenderDoc。这个工具可以捕捉应用一帧的数据,将其进行的操作,绘制图形的数据,经历地各个阶段统统捕捉到,然后以一种非常直观的方式呈现给使用者。说实话,用起来太爽了!
虽然RenderDoc功能十分强大,但它却是极其简单,到底有多简单,跟着笔者走一遍就知道了。运行exe,点击File->Capture Log打开如下的界面:
初始界面
1、设置运行exe和路径
设置方法如图设置好Executable Path和Working Directory之后,点击Launch按钮
2、进行捕捉
如果你启动的应用和下图类似(上面有捕捉提示),就说明你成功了,按F12或者PrintScreen键就可以捕捉一帧。
启动效果
捕捉到后,RenderDoc界面上会多出一张捕捉到的图像:
捕捉后的结果
3、数据分析
双击捕捉到的图像,RenderDoc就会自动分析数据,你就可以在左边看到调用的函数,在Pipeline State标签、Mesh Output标签、Texture Viewer标签下看到相应的信息:
分析效果
很酷吧,更深层的功能还需要多用,多研究才行。详细的信息你可以参考其官网的帮助文档,多学多用才是编程之道。
预防BUG
一个优秀的程序员都有一颗预防BUG的心,预防BUG的工具有两个:1、一个良好的编码习惯;2、一颗专心编码的心。
笔者经常翻阅两本书来塑造自己的编码习惯,一本是《代码大全2》,另一本则是《重构:改善既有代码的设计》。《代码大全2》是当之无愧的软件构建第一书,这本书指点了笔者如何进行代码设计、编写具有很高可读性的代码。笔者时不时就会翻出来读一读,每次都能有新的收获,这是一本能陪伴你成长的书。《重构:改善既有代码的设计》一书主要是在笔者写完代码之后,回过头来看自己写的代码,看看有没有能提炼重用或者是增加可读性的修改方法的。
要有一颗专心编码的心,就需要你对分心会有多大的坏处有一个直观的认识。笔者从《专注:把事情做到极致的艺术》一书中认识到,分心会对当前做的事情造成巨大的影响,降低大约30%的效率与准确度。认识到这点之后,笔者采用了一个小技巧来克服(或者说努力克服)分心,那就是:根据自己的状态,设定90min、45min或者25min的专心编码时间,这个时间内如果有人打扰,需要礼貌地拒绝以便自己能专心编码。到时间之后,休息15min、5min、5min时间,这时间内可以上网看些文章,或者和其他的程序员交流交流来放松。切记不能一下子就设定超过90min的时间,这对培养专心的习惯不利。
总结
本文中,我们学到了如何使用glGetError函数和glDebugOutput函数来捕捉错误,也尝试了输出帧缓存中的数据以便确定中间数据是否正确。RenderDoc是一个强大的工具,可以帮助我们分析一帧的数据,方便我们定位问题。当然,事后改BUG比不上事先预防BUG,我们可以通过两个工具来预防BUG:一个好的编码习惯和一个专心编码的心。
参考文献
Debugging:非常好的一篇文章,本文的大部分内容都是从这里来的
RenderDoc官方网站