游戏渲染器的编写心得

renderer

Tinyrenderer3D和Tinyrenderer2D的编写心得

这几天写了3D和2D的渲染器,原本是只打算编写一个2D渲染器的,但是在写的过程中发现2D能写的很少,用不到我学的所有东西,所以就在2D的基础上编写的3D的渲染器。

渲染器地址:2D3D

2D渲染器的心得

2D的话我打算写一个比SDL强一些的渲染器的,主要是想要这种五彩斑斓的多边形:

polygon

而且确实也写成了。这个渲染器给我的收获就是让我明白了SDL的渲染器是如何构建的,虽然说我没有看过SDL的源码,但是我参考着SDL的API,将渲染器部分重要的API都实现了一遍。这种五彩斑斓的五边形的API设计是参考SFML的。

SDL本身是有两种渲染器,在SDL1的时候是软渲染,渲染用不到GPU,完全的软件,所以这个时候用的是SDL_Surface用来代表图像,并且可以直接通过成员获得其图像数据和格式。但是在SDL2时出现了基于OpenGL和DX的渲染器,使用SDL_Texture表示图像,充分利用了GPU。所以SDL_Texture是不能直接获取图像数据,图像格式的,因为他们都保存在了GPU中,其加载图片时执行了类似这样的代码:

1
2
3
4
5
6
int w, int h;
unsigned char* data = load_image(filename, &w, &h);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGB, GL_UNSIGNED_BYTE, data);
glBindTexture(GL_TEXTURE_2D, 0);
free_image(data);

将图片数据传给GPU之后就可以将内存中的数据删除了,所以每次调用GetPixel()这样的数据的话必须问GPU要像素,很耗时间。

至于可以将Texture作为渲染器的目标,这是使用了帧缓冲做出来的。

3D渲染器的心得

到了3D渲染器这方面就裂开了,因为之前没有深入用过3D引擎的经历,只用过一点Pandas。而且市面上一般都是3D游戏引擎,纯写渲染器的好像都是用于建模软件的静态渲染器,也没找着什么源码来参考。当时在写的时候参考了一下Irrlicht引擎的API,然后从头开始自己摸索组织代码。

这一部分跟着Learning OpenGL的教程来实现的,其实这个教程我之前走过,当时是每个部分分开写的,所以感觉没什么难度。这一次我得把所有的东西全放到一起,感觉难度瞬间就上来了。

错误处理

首先最令人头疼的就是OpenGL的错误处理,这一点我只能说Vulkan验证层牛逼。原本的教程中没说这方面,我去B站搜了视频才知道方法。主要有两个方法:

  • 首先是最原始的glGetError()函数。OpenGL会把错误放入到队列中,任何一个函数出错了,它会将错误信息放入队列,然后可以通过这个函数从队列里面拿一个错误出来。如果队列是空就会返回GL_NO_ERROR。所以跟着视频我编写了这样的函数:

    inline void GLClearError() {
        while (glGetError() != GL_NO_ERROR);
    }
    
    inline void GLPrintError(const char* function, int line) {
        GLenum error;
        while ((error = glGetError()) != GL_NO_ERROR) {
            printf("[OpenGL Error](%s - %d): %s\n",
            function, line, ErrorCode2Str(error).c_str());
        }
    }
    
    #define GLCall(x) \
        do { \
            GLClearError(); \
            x; \
            GLPrintError(__FUNCTION__, __LINE__); \
        } while(0)
    

    这样的的话每次调用函数都得裹上一层GLCall宏来判断错误。这个东西的好处就是所有版本OpenGL都支持,缺点就是他只返回一个错误码,要知道什么错误还得去查码。

  • 或者使用GL_ARB_debug_output拓展,这个拓展会自动在出错的时候在控制台上输出信息,类似于Vulkan的验证层,不过很可惜的是我电脑不支持这个。。。

这些错误中最令人头疼的就是GL_INVALID_OPERATION,这个错误说你的操作在当前状态下不行。意思就是说我也不知道为什么错了,你自己查去。

每每遇到这个错误,都要花费我好长时间去调试,现在总结起来,一般都是调用glBufferData,glVertexAttribPointer这类需要缓冲的函数时,当前绑定的缓冲不存在或者是0,这样由于当前OpenGL状态不正确,所以会报错。

有东西没画出来

第二令人头大的是程序也没报错,但是窗口上就有些该出现的东西没出现,有些东西画不出来。主要有两个原因:

  • 着色器问题:可能存在uniform类型变量没有赋值,或者直接被删了。这里比较坑的是如果一个uniform变量你在着色器中没有使用,它会给你删了,然后在使用glGetUniformLocation()函数的时候直接返回-1。

    而且比较坑的是,如果你想这样定义了一个函数:

    1
    2
    3
    4
    5
    6
    
    uniform sampler2D shadow_map;
    
    float CalcShadow() {
      // use shadow_map
      ...
    }
    

    在函数里面用了uniform变量,但是你却没有调用过这个函数的话,这个变量默认还是会被删除!所以可以推测在编译着色器程序的时候,OpenGL会将没用到的变量,函数都删了。

  • 视角问题:有时候MVP矩阵可能会出错,导致将物体移到了未知的地方。解决办法就是提前确保MVP矩阵没错,然后加一个可以自由移动视角的摄像机,这样当视野中没显示的时候可以转动摄像机看看。或者在绘制物体的时候输出物体变换后的坐标。

  • 面剔除问题:可能物体的顶点不符合当前设置的面剔除规则,OpenGL帮你剔除了。解决办法就是关闭面剔除再转一遍。

而且在绘制阴影的时候,也比较难确保阴影究竟有没有绘制成功,这个时候需要将阴影贴图画出来看一看。

综上所述,通过这些经验我可以做一个简单的单元测试框架,来查看发生问题的原因(当时着急赶进度没写,现在后悔了😭),这个框架的主要功能是:

  • 禁用面剔除,模板测试(有些时候模板测试也会带来一些问题)

  • 物体的位置永远在(0,0,0)以便于更好地判断位置问题

  • 多种绘制模式:

    • 只绘制线框
    • 只绘制颜色
    • 绘制贴图

    并且可以通过按键来自由调控,这样的话就可以判断到底是在哪个阶段出了问题。

    如果是线框出问题,那可能是顶点没有传输到GPU中或者VAO,EBO有问题。

    如果是颜色没画出来,那可能是片段着色器出问题,或者颜色没传到GPU中。

    如果是贴图没画出来,就要检查贴图方面的问题,贴图加载是否成功,有没有传到GPU中,纹理格式设置对不对等。

其实针对光照和其他的功能还得写针对的框架,这里就不赘述了。

着色器

第三个令人头疼的是着色器,以前跟教程的时候都是每个章节新开一个着色器,着色器中就那么点功能。但是现在我的着色器又要绘制纯色,又要绘制纹理,又要光照,还得在有没有法线纹理之间来回切换。有些需要将顶点和颜色一起通过layout传入,有些只要传入顶点,但是需要纹理。

我第一个解决办法是将着色器分类,比如纯色的着色器放一个文件,贴图的着色器放一个文件。但是当我写到光照贴图部分的时候就不行了,因为我的API是这样设计的:

1
2
3
4
5
6
plane_.material.ambient = {0.6, 0.6, 0.6};
plane_.material.diffuse = {0.6, 0.6, 0.6};
plane_.material.specular = {0.6, 0.6, 0.6};
plane_.material.shininess = 32;

plane_.material.diffuse_texture = texture;

每个物体都有环境光,漫反射和镜面三种属性,你可以通过数值直接指定,或者给他贴图。当有贴图的时候相应的数值部分就不再发挥作用。

这就导致我没得办法将着色器分类,我总不能为纯色写一个,为有漫反射贴图的写一个,再给镜面光的写一个吧,这样着色器数量也太多了。

最后没得办法,只能把所有的着色器并成一个,然后在里面使用bool类型的uniform变量指定是否存在贴图。

结果就是着色器很长,而且我认为那些bool变量没什么必要,还让我的uniform数量减少了(我电脑上最多一次性支持50个uniform),将这些变量去掉我能多加一些光源呢。但是目前我没更好的办法,只能这样将就一下。

天空盒子

以前我写天空盒就遇到过坑:天空盒的六个图像大小必须一样。

这次又遇到一个:图像边长必须为2的倍数。

总结

这次编写渲染器不仅仅让我复习了OpenGL,也让我欣赏到了凌晨三四点的风景,让我在半夜看着屏幕血压飙升,以及连续5天中午吃泡面的艰辛历程。

updatedupdated2023-06-082023-06-08