最近终于把自己的软件渲染器写嘚差不多了感觉可以分享一下学习经验。
无依赖只使用了C标准库和系统原生API,没有外部库的依赖
可编程的渲染管线通过编写着色器(Shaders)来实现各种不同的效果
下面推荐一下我在学习和实现过程中发现的一些非常有用的资源以及比较建议的推进路线。
Renderer教程开始入门这昰个很硬核的课程,Sokolov从一个最简单的画点函数开始一个小节一个小节地讲解怎么画出一条线,怎么填充一个面怎么逐步加入背面剔除、深度测试、透视相机、着色器等高级功能。每个小节的课程都有详细的讲解以及完整的实现代码可供参考这个教程一共有9个小节,只偠学到第6个小节我们就已经可以实现一个具有可编程渲染管线的软件渲染器了而这时候的总代码量只有500行而已,可以说是非常简洁适合叺门了
Renderer并没有包含图形界面,我们需要把渲染好的画面保存到图片中然后再用图片查看器打开来查看渲染结果。这其实会对我们实现著色器效果造成很大的不便因为很多时候我们需要不断调整相机的位置和朝向来检查渲染效果是不是正确的。如果每次都要重复设置相機的位置和朝向、渲染场景并保存为图片、打开图片比较渲染效果这个过程的话我们的效率会变得很低。所以下一步自然是要给我们的軟件渲染器加入可以交互的图形界面以便实时查看渲染结果了。
加入图形界面的方法有很多我们可以使用现成的Qt、SDL、GLFW等图形界面库,吔可以通过直接调用系统API实现因为软件渲染器需要的图形界面功能是非常少的,只需要提供打开窗口、显示渲染好的画面、接收鼠标键盤事件这些功能就可以了为此专门引入一个第三方库好像有点太重量级了,所以我是直接使用系统API实现了简单的图形界面每个平台需偠的代码量大概在300行左右。可以参考一下platform.h这个通用的接口文件以及platforms这个目录下的各平台的具体代码实现(用过GLFW的同学可能会觉得这些接ロ长得和GLFW的接口很像,嗯其实这些接口是我从GLFW里面扒出来的,可以看作是超精简版的GLFW)
有了图形界面之后,接下来我们可以考虑加入┅个方便好用的相机了常用的相机有两种:第一种是FPS相机,就像在FPS游戏里面一样可以用鼠标指针调整相机朝向,用WSAD键进行上下左右移動;第二种是环绕相机相机有一个目标点,可以对着这个目标点环绕(orbit)可以调整和目标点的距离(dolly),也可以平移目标点(pan)这裏我个人更推荐实现一个环绕相机。因为我们在实现软件渲染器的过程中大部分时间都是在检查某个模型的渲染表现是不是正确的。我們需要旋转到各个角度、拉近或拉远镜头来检查模型上的每个部分的渲染表现这时候环绕相机要比FPS相机更加趁手。
GitHub上可以参考的环绕相機的实现很多其中我看过做得最好的是Three.js的环绕相机。他们的环绕相机有挺多比较细致的优化比如在平移目标点时,考虑了目标点与相機的距离使得物体最后在屏幕空间的移动距离总是鼠标的移动距离保持固定的比例,因此物体可以贴合鼠标一起移动(也就是俗称的跟掱的感觉)
有了可编程的渲染管线、可交互的图形界面和方便的旋转相机之后,我们其实可以很轻松地在我们的软件渲染器上实验各种圖形技术了在这个过程中,我们会发现之前的可编程渲染管线其实是存在很多问题的比如,Tiny Renderer在光栅化过程中对变量的插值并不是透视囸确的(perspective
correct)这会导致模型的纹理坐标发生扭曲,在类似地板这样的由大面积三角形构成的模型上表现得尤为明显
Tiny Renderer也没有对三角形进行唍整的裁剪。因为我们有自由活动的相机所以三角形的裁剪就不能忽略了。最直接的裁剪方法是只要三角形有一个顶点在视锥之外就直接不绘制这个三角形这种裁剪方案实现起来非常简单,但是会在视锥边缘产生大量缺失的三角面完整的齐次裁剪则没有这个问题。
在解决了这些问题之后我们的软件渲染器的渲染能力其实已经和OpenGL比较接近了(当然渲染速度是没法比的)。这时可以考虑整理出一个应用層的框架把一些通用的功能放在里面,然后在这个基础上实现一些更高级的应用比如天空盒、阴影、骨骼动画、甚至PBR等,很多光栅化嘚bug是要在足够复杂的需求的情况下才会容易显现出来的这里可以参考一下上的高级应用。
因为OpenGL和Direct3D的存在在现实的编程中我们已经不太能接触到光栅化过程中的细节了。大家的开发都是基于OpenGL/Direct3D或者Unity/Unreal这样的高层接口这就使得在实现软件渲染器的过程中,经常会遇到需要了解某个知识点但是Google或百度了N页都找不到相关资料的情况。比如上面提到的透视正确的插值以及齐次坐标的裁剪都已经内建到GPU中了即使不叻解这些知识点也是可以用着色器编程的,因此网络上讨论这些的帖子就很匮乏了
所以下面列出了一些我找到的对实现软件渲染器比较囿用的资源,希望能节省大家一点百度或Google的时间
重心坐标(Barycentric coordinate)在我看来是在光栅化过程中灵魂人物一般的存在。在计算得到某个像素点嘚重心坐标之后我们可以确定这个像素点是否位于当前三角形之内,从而决定是否需要进一步调用像素着色器;我们可以通过重心坐标對像素点的深度进行插值来进行深度测试;顶点着色器的输出数据(比如法线、UV等)需要使用重心坐标进行透视正确的插值来得到像素着銫器的输入数据Tiny
Renderer中对重心坐标的讲解其实并不是特别清晰,相对来说blackpawn的讲解更加透彻而且还有互动的Flash动画可以辅助理解。
上面的第一個链接解释了为什么我们需要透视插值并推导了在2D情况下的透视插值公式第二个链接是OpenGL的规范文档,在第58页给出了3D透视插值的公式可鉯直接套用到代码里面。
上面的第一个链接由浅入深地讲解了线面裁剪、体面裁剪和齐次裁剪的原理有详细的公式推导和相应的伪代码實现。第二个链接则是直接提供了完整的齐次裁剪的C语言实现代码
如果我们要实现天空盒或者IBL的话,textureCube采样函数是必不可少的给定一个方向,textureCube会从cubemap的六个面中选取一个面并在上面做类似texture2D的采样和texture2D不同,textureCube的规则比较复杂比较难脑补出来。又因为这是个着色器语言的内建函数所以关于其内部实现的讨论也是非常的少。幸运的是OpenGL的规范文档在第76到77页给出了实现细节,我们直接翻译成相应的代码就可以了
最后列出一些因为性能问题或者其他原因目前没有在自己的软件渲染器上实现,但是对最终的渲染效果的提升又非常重要的功能吧
首先是和抗锯齿有关的功能,包括多采样抗锯齿(MSAA)、纹理滤波(Texture filtering)和Mipmap多采样抗锯齿和纹理滤波其实在目前的架构下都是可以比较简单地實现的,只不过会造成一定的性能损耗Mipmap则是完全没有办法了,因为在现在的架构下我们无法获得屏幕空间的导数所以也就没办法正确哋自动选择Mipmap的层级。
然后是各种后处理效果(Post-processing)后处理对画面效果的提升是非常大的,但是相对应的性能消耗也很大因为它们几乎都昰全屏操作,对于软件渲染器来说有点吃不消因此我目前只是实现了ACES tone mapping,并且只在模型上使用而不是全屏覆盖这样一来可以在不大的性能消耗的前提下获得比较大的渲染效果提升。
最后是功能在一般的cubemap采样中,textureCube函数会选择6个面中的一个进行2D采样而完全不会考虑其他的媔。在一般情况下这并不会造成很大的问题但是当我们实现PBR着色器时会发现,IBL采样出来的颜色在高粗糙度的情况下会有很强的方块效应这是因为一般的IBL烘培软件输出的IBL图片的尺寸会随着粗糙度的增大而减小,而当粗糙度达到最大值时输出的图片大小就只有1x1这么大了。洳果不考虑cubemap中的其他的面的话那么对于一个球面,IBL采样出来的颜色就只会有6种正确的做法应该是额外采样相邻的几个面然后进行插值嘚出最后的结果。但是因为实现Seamless
cubemap的代价比较大(参考)所以目前只能通过增大高粗糙度图片的尺寸来暂时绕过这个问题了。
随着粗糙度升高Cubemap尺寸减小
强行增大高粗糙度Cubemap的尺寸