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),我们主要介绍 glGetError
和 debug output
这两个 API。
1、glGetError
glGetError
的定义比较简单:
GLenum glGetError();
即函数返回一个错误码,错误码有如下 8 种:
这 8 种错误码最常见的就是 GL_INVALID_ENUM
、GL_INVALID_VALUE
、GL_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)
如上是简单的使用示例,无错误情况返回 0
,glTexImage2D
第一个参数传入 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
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) 的多线程调用(而且作者也没有支持的打算)。