从0开始的OpenGL学习(三十六)-Debugging

Debug

从0开始的OpenGL学习系列目录

说到编程,写代码,有一个我们永远绕不过去的话题就是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官方网站

你可能感兴趣的:(从0开始的OpenGL学习(三十六)-Debugging)