OpenGL (ES) 调试总结

November 20, 2022

OpenGL 的调试或者说图形 API 的调试,相比普通的程序调试(如C++/Java)有不少区别,主要有:

  • CPU 端仅提交数据&指令,驱动 / GPU 的渲染实现是一个“黑盒”
  • OpenGL Context 是一个巨大的状态机,很多内部状态不能直接读写
  • CPU/GPU 内存隔离,GL 对象(纹理、buffer 等)由内部创建/释放,无法直接查看内存数据
  • 异步执行,无法跟踪渲染指令的具体执行,GPU 端渲染流水线无法断点
  • Shader 代码无法在运行时断点调试,无法查看 Shader 中各变量的值

因此调试难度相对更高,这里针对 OpenGL (ES) 对一些常用调试方法做一个梳理。

一、调试相关 API

早期的 OpenGL 版本中,glGetError 是唯一的错误检查 API,使用简单,对执行环境 (Context) 无要求,但错误信息简略,不便于问题的快速定位,到了 OpenGL 4.3/OpenGL ES 3.2 版本,核心标准加入了 debug output 相关 API,报错信息更完善,并新增了不少新特性,同时不再默认开启(需要 glEnable 手动打开),而到了 Vulkan 时代,则设计了 Validation Layer 的概念,将错误检查、性能监控等调试功能交给开发者自己实现,以保持驱动本身的简洁和高性能。这里针对 OpenGL (ES),我们主要介绍 glGetErrordebug output 这两个 API。

1、glGetError

glGetError 的定义比较简单:

GLenum glGetError();

即函数返回一个错误码,错误码有如下 8 种:

这 8 种错误码最常见的就是 GL_INVALID_ENUMGL_INVALID_VALUEGL_INVALID_OPERATION 这 3 种,
分别对应非法枚举、非法数值和非法错误,glGetError 的使用有如下特点:

  • 默认值为 0 (GL_NO_ERROR)
  • 一个 Context 只会记录一个错误 (相当于状态机内有个全局 error 状态变量)
  • glGetError 返回后清除 (重置为 GL_NO_ERROR)
  • 会触发渲染流水线同步 (影响性能)
glBindTexture(GL_TEXTURE_2D, tex);
std::cout << glGetError() << std::endl; // returns 0 (no error)

glTexImage2D(GL_TEXTURE_3D, 0, GL_RGB, 512, 512, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
std::cout << glGetError() << std::endl; // returns 1280 (invalid enum)

glGenTextures(-5, textures);
std::cout << glGetError() << std::endl; // returns 1281 (invalid value)

std::cout << glGetError() << std::endl; // returns 0 (no error)

如上是简单的使用示例,无错误情况返回 0glTexImage2D 第一个参数传入 GL_TEXTURE_3D 触发 GL_INVALID_ENUM 错误,glGenTextures 第一个参数传入 -5 触发 GL_INVALID_VALUE 错误,glGetError 后重新调用,返回 0

事实上我们为了代码的简洁,通常会定义 debug 宏来包装 glGetError,如 bgfx 开源库中的定义

#define _GL_CHECK(_check, _call) \
                BX_MACRO_BLOCK_BEGIN \
                    /*BX_TRACE(#_call);*/ \
                    _call; \
                    GLenum gl_err = glGetError(); \
                    _check(0 == gl_err, #_call "; GL error 0x%x: %s", gl_err, glEnumName(gl_err) ); \
                    BX_UNUSED(gl_err); \
                BX_MACRO_BLOCK_END

#define IGNORE_GL_ERROR_CHECK(...) BX_NOOP()

#if BGFX_CONFIG_DEBUG
#   define GL_CHECK(_call)   _GL_CHECK(BX_ASSERT, _call)
#   define GL_CHECK_I(_call) _GL_CHECK(IGNORE_GL_ERROR_CHECK, _call)
#else
#   define GL_CHECK(_call)   _call
#   define GL_CHECK_I(_call) _call
#endif // BGFX_CONFIG_DEBUG

然后在所有 OpenGL (ES) 函数调用的地方加上 GL_CHECK

GLuint fbo;
GL_CHECK(glGenFramebuffers(1, &fbo) );
GL_CHECK(glBindFramebuffer(GL_FRAMEBUFFER, fbo) );

GLuint id;
GL_CHECK(glGenTextures(1, &id) );
GL_CHECK(glBindTexture(GL_TEXTURE_2D, id) );

可以看到,glGetError 虽然使用上简单,但是有明显的不足之处:

  • 仅几个固定错误码,错误信息不明确
  • 无法快速定位具体的错误 API 调用,只能在每个 API 调用后进行检查

2、debug output

debug output 是新版的调试 API,在 OpenGL 4.3、OpenGL ES 3.2 已纳入核心标准,低版本可通过 KHR_debug 扩展使用,移动端 ARM Mali 系列、高通 Adreno 系列 GPU 都已支持,相比 glGetError,debug output 提供了更丰富的调试功能,支持更多新特性:

  • 系统日志输出
  • 应用程序自定义日志插入
  • Get/Callback 两种日志获取方式,Callback 支持同步/异步
  • 日志过滤
  • 日志分组 ( Scoping )
  • GL 对象命名

首先看下具体的 API 定义:

  • 日志获取
    void glDebugMessageCallback(DEBUGPROC callback, void* userParam)
    GLuint glGetDebugMessageLog(GLuint count, GLsizei bufSize, ... )
  • 添加自定义日志
    void glDebugMessageInsert(GLenum source, GLenum type, ... )
  • 日志过滤
    void glDebugMessageControl(GLenum source, GLenum type, ... )
  • 日志分组
    void glPushDebugGroup(GLenum source, GLuint id, ...); 
    void glPopDebugGroup();
  • 对象命名
    void glObjectLabel(GLenum identifier, GLuint name, ...); 
    void glObjectPtrLabel(void * ptr, GLsizei length, ...); 
    void glGetObjectLabel(GLenum identifier, GLuint name, ...); 
    void glGetObjectPtrLabel(void * ptr, GLsizei bufSize, ...);

debug output 详细的文档可参考:Debug Output ,这里在 Android 平台简单测试一下:

PFNGLDEBUGMESSAGECALLBACKKHRPROC glDebugMessageCallbackKHR;  
PFNGLDEBUGMESSAGEINSERTKHRPROC glDebugMessageInsertKHR;  

void MessageCallback(GLenum source, GLenum type, GLuint id, GLenum severity, GLsizei length,  
                     const GLchar *message, const void *userParam) {  
  LOGD("GL CALLBACK: %s type = 0x%x, severity = 0x%x, message = %s\n",  
       (type == GL_DEBUG_TYPE_ERROR ? "** GL ERROR **" : ""), type, severity, message);  
}  

extern "C" JNIEXPORT void JNICALL  
Java_me_robot9_test_MainActivity_testGLDebug(JNIEnv *env, jobject /* this */) {  
  LOGD("test GL debug output");  
  glDebugMessageCallbackKHR = (PFNGLDEBUGMESSAGECALLBACKKHRPROC) eglGetProcAddress("glDebugMessageCallbackKHR");  
  glDebugMessageInsertKHR = (PFNGLDEBUGMESSAGEINSERTKHRPROC) eglGetProcAddress("glDebugMessageInsertKHR");  

  glEnable(GL_DEBUG_OUTPUT_KHR);  
  glDebugMessageCallbackKHR(MessageCallback, nullptr);  

  GLint len = 10;  
  const GLchar *shaderStr = "test";  
  glShaderSource(1, 1, &shaderStr, &len);  

  std::string err = "Test OpenGL Error String";  
  glDebugMessageInsertKHR(GL_DEBUG_SOURCE_APPLICATION, GL_DEBUG_TYPE_ERROR, 0,  
                          GL_DEBUG_SEVERITY_HIGH, -1, err.c_str());  
}

可以看到首先定义日志回调 MessageCallback,然后 glEnable 开启调试功能,再设置日志回调,然后用 glShaderSource 产生一个系统错误,用 glDebugMessageInsertKHR 插入一个自定义错误,LogCat 日志输出如下:

对比 glGetError,日志信息更详细可读,不过需要注意的是,debug output 的开启会影响性能,需要在正式上线时关闭。

二、存图调试

在一些 OpenGL (ES) 应用场景,会有类似滤镜链的调用逻辑,这时候可以对中间结果 ( texture / framebuffer ) 进行存图调试


存图调试的实现相对简单,核心就是调用 glReadPixels 将 fbo 的 read buffer 下载到 CPU 端内存,然后保存为 jpg/png 图片进行查看

uint fbo;  
glGenFramebuffers(1, &fbo);  
glBindFramebuffer(GL_FRAMEBUFFER, fbo);  
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, textureId, 0);  

glReadPixels(0, 0, width, height, GL_RGBA, GL_UNSIGNED_BYTE, pixels);
// save pixels buffer to  jpg/png file
...

glBindFramebuffer(GL_FRAMEBUFFER, 0);  
glDeleteFramebuffers(1, &fbo); 
...

三、Shader 调试

Shader 作为现代可编程渲染管线必不可少的部分,其调试方法对比普通的程序调试也有较大差别,如下是 OpenGL (ES) 中 Shader 的使用过程:

OpenGL (ES) 提供了 Compile 和 Link 过程的错误检查机制:

1、编译

针对编译过程的错误,我们可以使用 glGetShaderiv 获取 GL_COMPILE_STATUS 的值,如果为 GL_FALSE,说明编译失败,然后再用 glGetShaderInfoLog 获取错误日志:

GLuint shader = glCreateShader(...);

// Get strings for glShaderSource.
glShaderSource(shader, ...);

glCompileShader(shader);

GLint isCompiled = 0;
glGetShaderiv(shader, GL_COMPILE_STATUS, &isCompiled);
if(isCompiled == GL_FALSE)
{
    GLint maxLength = 0;
    glGetShaderiv(shader, GL_INFO_LOG_LENGTH, &maxLength);

    // The maxLength includes the NULL character
    std::vector<GLchar> errorLog(maxLength);
    glGetShaderInfoLog(shader, maxLength, &maxLength, &errorLog[0]);

    // Provide the infolog in whatever manor you deem best.
    // Exit with failure.
    glDeleteShader(shader); // Don't leak the shader.
    return;
}

// Shader compilation is successful.

这里跑一个实际的例子:

char vShaderStr[] =
  "#version 300 es                            \n"
  "layout(location = 0) in vec3 vPosition;    \n"
  "void main()                                \n"
  "{                                          \n"
  "   gl_Position = vPosition;                \n"
  "   callTest();                             \n"
  "}                                          \n";

可以看到产生了两个错误:vec3 赋值给 vec4,以及 callTest 函数不存在。

2、链接

链接错误的检查和编译类似,使用 glGetProgramiv 获取 GL_LINK_STATUS 的值,如果为 GL_FALSE,说明编译失败,然后再用 glGetProgramInfoLog 获取错误日志:

// Note the different functions here: glGetProgram* instead of glGetShader*.
GLint isLinked = 0;
glGetProgramiv(program, GL_LINK_STATUS, (int *)&isLinked);
if (isLinked == GL_FALSE)
{
    GLint maxLength = 0;
    glGetProgramiv(program, GL_INFO_LOG_LENGTH, &maxLength);

    // The maxLength includes the NULL character
    std::vector<GLchar> infoLog(maxLength);
    glGetProgramInfoLog(program, maxLength, &maxLength, &infoLog[0]);

    // We don't need the program anymore.
    glDeleteProgram(program);
    // Don't leak shaders either.
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);

    // Use the infoLog as you see fit.

    // In this simple program, we'll just leave
    return;
}

同样我们跑一个例子:

char vShaderStr[] =
  "#version 300 es                            \n"
  "layout(location = 0) in vec4 vPosition;    \n"
  "out vec4 testVec;                          \n"
  "void main()                                \n"
  "{                                          \n"
  "   gl_Position = vPosition;                \n"
  "   testVec = vPosition;                    \n"
  "}                                          \n";

char fShaderStr[] =
  "#version 300 es                            \n"
  "precision mediump float;                   \n"
  "in vec4 testVec2;                          \n"
  "out vec4 fragColor;                        \n"
  "void main()                                \n"
  "{                                          \n"
  "   fragColor = testVec2;                   \n"
  "}                                          \n";

即 fragment shader 阶段的输入 testVec2 没用在前面 stage 中作为输出定义,所有链接失败。

3、颜色输出

使用 Shader 输出一些数值/变量到屏幕上查看也是一种常用的调试方法,比如我们将模型的法向向量作为颜色输出,即可在屏幕上看到偏蓝色的图像:

#version 330 core
out vec4 FragColor;
in vec3 Normal;
[...]

void main()
{
    [...]
    FragColor.rgb = Normal;
    FragColor.a = 1.0f;
}

这里推荐阅读龚大的一篇文章:GPU画像素的顺序是什么

4、数值打印

事实上,用 Shader 来显示数值也是可能的,类似单片机数码管显示一样,我们可以在屏幕上把数字(0-9) 打印出来:

vec3 print_n(in vec2 uv, float val);
int PrintUInt(in vec2 uv, in uint value, const int maxDigits);

void mainImage(out vec4 fragColor, in vec2 fragCoord) {
    float val0 = 0.2;
    uint val1 = 1234u;

    vec2 uv = fragCoord/iResolution.y;
    fragColor = vec4(print_n(uv+vec2(-0.5, -0.6), val0), 1.);
    fragColor += vec4(float(PrintUInt(uv*15.+vec2(-7.5, -6.5), val1, 10)));
}

int get_fvalue(float val, int idx) {
    idx=clamp(idx, 0, 10);
    for (int i=0;i<idx+1;i++) {
        val=(val-floor(val/10.)*10.)*10.;
    }
    return int(val/10.);
}

float getarr(in mat3 arr, int idx) {
    idx=max(idx, 0);ivec2 ij=ivec2((idx/3)%3, idx%3);
    if (ij==ivec2(0, 0))return arr[0][0];
    if (ij==ivec2(0, 1))return arr[0][1];
    if (ij==ivec2(0, 2))return arr[0][2];
    if (ij==ivec2(1, 0))return arr[1][0];
    if (ij==ivec2(1, 1))return arr[1][1];
    if (ij==ivec2(1, 2))return arr[1][2];
    if (ij==ivec2(2, 0))return arr[2][0];
    if (ij==ivec2(2, 1))return arr[2][1];
    if (ij==ivec2(2, 2))return arr[2][2];
    return arr[0][0];
}

float print_num(vec2 uv, float value, int num) {
    const mat3 fontb=mat3(vec3(480599.0, 139810.0, 476951.0), vec3(476999.0, 350020.0, 464711.0), vec3(464727.0, 476228.0, 481111.0));
    const mat3 powers = mat3(vec3(1., 10., 100.), vec3(1000., 10000., 100000.), vec3(1000000., 10000000., 100000000.));
    if (uv.y < 0.0 || uv.y >= 1.0) return 0.0;
    if (uv.x < -6.0 || uv.x >= 10.0) return 0.0;
    float bits = 0.0;
    int di = - int(floor(uv.x))+ 1;
    if (-di <= num) {
        float pw = getarr(powers, di);
        float val = abs(value);
        float pivot = max(val, 1.5) * 10.0;
        if (pivot < pw) {
            if (value < 0.0 && pivot >= pw * 0.1) bits = 1792.0;
        } else {
            if (di == 0) {
                if (num > 0) bits = 2.0;
            } else {
                int idx=0;
                if (di < 0)idx=get_fvalue(val, int(-di)); else idx=(int(val*10.) / int(pw))%10;
                if (idx<=9 && idx>=0)bits = idx<9?getarr(fontb, idx):481095.0;
            }
        }
    } else return 0.;
    return floor(mod(bits / exp2(floor(fract(uv.x) * 4.0) + floor(uv.y * 5.0) * 4.0), 2.0));
}

vec3 print_n(in vec2 uv, float val){
    int numbers = 6;
    vec2 font = vec2(15.);
    float d = print_num(uv*font, val, numbers);
    return vec3(1.0, 1.0, 1.0)* d;
}

const uint[] ufont = uint[](0x75557u, 0x22222u, 0x74717u, 0x74747u, 0x11574u, 0x71747u, 0x71757u, 0x74444u, 0x75757u, 0x75747u);
const uint[] upowers = uint[](1u, 10u, 100u, 1000u, 10000u, 100000u, 1000000u, 10000000u, 100000000u, 1000000000u);

int PrintUInt(in vec2 uv, in uint value, const int maxDigits) {
    if (abs(uv.y-0.5)<0.5) {
        int iu = int(floor(uv.x));
        if (iu>=0 && iu<maxDigits){
            uint n = (value/upowers[uint(clamp(maxDigits-iu-1, 0, 9))]) % 10u;
            uv.x = fract(uv.x);
            uvec2 p = uvec2(floor(uv*vec2(4.0, 5.0)));
            return int((ufont[n] >> (p.x+p.y*4u)) & 1u);
        }
    }
    return 0;
}

ShaderToy 链接:Dd2GWt,虽然看起来比较技巧性,但有时候也确实是一种便捷的调试手段,不需要额外的工具支持。

5、离线调试

Shader 代码作为字符串提交给 GPU 驱动,由驱动/GPU来完成编译/链接/执行,各厂商的实现都不一样,同时 Shader 语言本身也有多种规范,如 GLSL/HLSL/MSL 等,而 Khronos 之后推出了 SPIR-V 中间语言格式,各类 Shader 语言之间的转换开始出现可行性,同时也为 Shader 的调试提供的更多的可能性。

这里推荐 SHADERed 这个项目,提供了集成式的 Shader 调试功能,包括:

  • 单步执行
  • 表达式
  • 变量监控
  • 断点/条件断点

本质上是它是先将 GLSL 编译为 SPIR-V 字节码,然后用 SPIR-V 虚拟机(解释器) 执行 (CPU 端),从而使单步/断点等功能变得可能,也可以理解为一种软件渲染,用 CPU 来执行 Shader。

出于个人兴趣,作者最近也在做一个类似的 SPIR-V 虚拟机项目 SPVM,感兴趣的可以关注一下。

6、shader 日志打印

Vulkan 中使用 GLSL 时可以使用 GL_EXT_debug_printf 这个扩展,来实现在 shader 代码中直接打印日志

This extension adds a "debugPrintfEXT" built-in function that is similar to printf in C, but is implemented by the Vulkan validation layers and the output goes to the debug output log.

#version 460
#extension GL_EXT_debug_printf : require

void main()
{
  debugPrintfEXT("Hello world!");
}

四、调试工具

图形/GPU 调试工具主要由厂商提供,各大 GPU 厂商都有提供自家产品对应的文档、工具等,然后还有一些开源的工具,如 RenderDoc / ApiTrace 等。

1、原理

调试工具的原理大部分都类似,即 record-replay 模式,先进行抓帧,将所有的图形 API 调用都记录下来,然后本地回放,调试就是在回放的过程中去观察数据、修改参数等。

2、厂商工具

移动端 GPU 相关的厂商工具主要有:

  • ARM:Graphic Analyzer
  • Qualcomm:Snapdragon Profiler
  • Google:Android GPU Inspector
  • Apple:Xcode

ARM Graphic Analyzer

Snapdragon Profiler

Android GPU Inspector

Xcode 不再支持 OpenGL ES

3、开源工具

开源工具中 RenderDoc 用的比较多,支持多种图形 API 和设备平台

RenderDoc 还支持 In-Application 模式,即在程序中加载 RenderDoc 模块,手动调用抓帧 API

#include "renderdoc_app.h"

RENDERDOC_API_1_1_2 *rdoc_api = NULL;

if(void *mod = dlopen("libVkLayer_GLES_RenderDoc.so", RTLD_NOW | RTLD_NOLOAD))
{
    pRENDERDOC_GetAPI RENDERDOC_GetAPI = (pRENDERDOC_GetAPI)dlsym(mod, "RENDERDOC_GetAPI");
    int ret = RENDERDOC_GetAPI(eRENDERDOC_API_Version_1_1_2, (void **)&rdoc_api);
    assert(ret == 1);
}

// To start a frame capture, call StartFrameCapture.
if(rdoc_api) rdoc_api->StartFrameCapture(NULL, NULL);

// Your rendering should happen here

// stop the capture
if(rdoc_api) rdoc_api->EndFrameCapture(NULL, NULL);

不过目前 RenderDoc 还不支持 OpenGL (ES) 的多线程调用(而且作者也没有支持的打算)。