Tinyrenderer3D和Tinyrenderer2D的编写心得
这几天写了3D和2D的渲染器,原本是只打算编写一个2D渲染器的,但是在写的过程中发现2D能写的很少,用不到我学的所有东西,所以就在2D的基础上编写的3D的渲染器。
2D渲染器的心得
2D的话我打算写一个比SDL强一些的渲染器的,主要是想要这种五彩斑斓的多边形:
而且确实也写成了。这个渲染器给我的收获就是让我明白了SDL的渲染器是如何构建的,虽然说我没有看过SDL的源码,但是我参考着SDL的API,将渲染器部分重要的API都实现了一遍。这种五彩斑斓的五边形的API设计是参考SFML的。
SDL本身是有两种渲染器,在SDL1的时候是软渲染,渲染用不到GPU,完全的软件,所以这个时候用的是SDL_Surface
用来代表图像,并且可以直接通过成员获得其图像数据和格式。但是在SDL2时出现了基于OpenGL和DX的渲染器,使用SDL_Texture
表示图像,充分利用了GPU。所以SDL_Texture
是不能直接获取图像数据,图像格式的,因为他们都保存在了GPU中,其加载图片时执行了类似这样的代码:
|
|
将图片数据传给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是这样设计的:
|
|
每个物体都有环境光,漫反射和镜面三种属性,你可以通过数值直接指定,或者给他贴图。当有贴图的时候相应的数值部分就不再发挥作用。
这就导致我没得办法将着色器分类,我总不能为纯色写一个,为有漫反射贴图的写一个,再给镜面光的写一个吧,这样着色器数量也太多了。
最后没得办法,只能把所有的着色器并成一个,然后在里面使用bool类型的uniform变量指定是否存在贴图。
结果就是着色器很长,而且我认为那些bool变量没什么必要,还让我的uniform数量减少了(我电脑上最多一次性支持50个uniform),将这些变量去掉我能多加一些光源呢。但是目前我没更好的办法,只能这样将就一下。
天空盒子
以前我写天空盒就遇到过坑:天空盒的六个图像大小必须一样。
这次又遇到一个:图像边长必须为2的倍数。
总结
这次编写渲染器不仅仅让我复习了OpenGL,也让我欣赏到了凌晨三四点的风景,让我在半夜看着屏幕血压飙升,以及连续5天中午吃泡面的艰辛历程。