美文网首页OpenGL程序员
从0开始的OpenGL学习(三十六)-Debugging

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

作者: 闪电的蓝熊猫 | 来源:发表于2018-03-01 21:10 被阅读197次
    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

        本文链接:https://www.haomeiwen.com/subject/gcmrxftx.html