实现一个软渲染器(CPU Renderer)

用CPU实现一个简易的渲染流水线的一些技术点。

Posted by John Young on June 16, 2020

一些题外话

最近从腾讯离职了,算是裸辞吧。 一是休息下;二是给自己一个充充电的机会,感觉技术栈还是有很多薄弱的地方;三是考虑下未来要做些什么。 辞职原因无非就是做的事情不喜欢了。 有的人问我为啥要裸辞,不喜欢了,继续混一混不也很好吗? 对这个问题,我觉得个人追求不一样吧,工作对我不仅仅只是混口饭吃,个人发展受限制了就必须得离开了。

前言

其实两、三年前实现过一个C#版本的软渲染器,但是后来看起来代码写的比较糟糕,也只是渲染了个立方体(12个面)而已,写在简历上挺丢人的。 这段时间闲下来了,觉得代码技能不能生疏,于是打算用C++写一个,算是重新拾起C++(写了两年C#)。 如果有(面试官/猎头/hr)看到了这篇文章,那么看看我的这个工程,应该能了解我C++的大致水平。 仓库地址: CpuRenderer

1. 第一次使用Cmake

之前我是直接用Visual Studio来写C++,VS本身是最流行的IDE,很多游戏引擎也都是VS作IDE的,例如Unity、EA的引擎。 后来看到很多开源的跨平台的C++项目都是用Cmake来管理构建的,而我基本只会运行Cmake,构建出静态库,然后用在自己的项目里。这时候其实就打算学习一下Cmake的,因为所有程序员都希望对代码库有最大程度的掌控,即使是第三方库,也最好能自己魔改,自己构建。然而后来一直拖延,觉得麻烦,就一直没有学。 很高兴,这是我的第一个用Cmake构建的项目,我算是学会了Cmake构建跨平台C++项目的基础用法。虽然很多是借鉴别的项目的CMakeLists.txt,但我已经了解了每一个语句是干什么的。 会者不难,难者不会。当你开始去做一件事,那这事很快就能做好,就是这么神奇。

2. 我实现软渲染器的步骤

一个软渲染器,算是一个比较小型的工程。然而如果没有划分好功能,循序渐进地去实现,那么即使你掌握计算机图形学的相关知识,实现起来也不会很容易,代码也可能会很混乱。 下面是我写代码的步骤,我会尽可能详细:

1)编写CMakeLists.txt

这一步其实就包含了挺多东西。首先是选择一个第三方的GUI类,因为我们不打算自己去实现一个跨平台的GUI类。这里我选择了SDL2,也是一个比较流行的跨平台轻量多媒体库(本来是打算用glfw的,但是在MacOS上,不能使用OpenGL非核心模式,这导致我们像素绘制很麻烦,需要用纹理绘制替代,于是放弃)。github上clone下代码后,在我们的Cmake里add_subdirectory,include_directories,然后target_link_library。此外我们还要魔改它的CmakeLists.txt,因为我们是静态链接,install的时候不想安装它生成的库,因此删去install的相关代码。 编写完成后,生成VS工程、make工程、xcode工程,确认正常后这一步算是完成了。

2)实现一个Hello Window,然后是视口类(ViewPort)

这一步我们主要目的是能得到一块可以进行像素操作的画布(canvas)。这里我用ViewPort类来管理,它是全局的,所以我们禁止复制(拷贝构造函数=delete)。在这个类中我们设置屏幕分辨率,维护一个frame buffer(后面的z-buffer也在这个类中维护),以及实现SetPixel函数。在程序的主循环的最后,我们调用SDL的渲染纹理的接口,将frame buffer的内容渲染到屏幕上。 我们需要简单测试一下帧率,例如每240帧切换一下颜色。在这一步我的垃圾cpu是能做到FPS > 240的,如果太慢了,可能是使用了比较慢的接口(例如SDL原生的SetPixel)。

3)实现DDA画线算法

我们新增一个Renderer类去操作ViewPort。在这个类里实现DDA画线算法,有了它,我们就有了画线框的能力。

4)实现sutherland hodgman裁剪算法

这一步可能有些人会有异议,因为一般这一步是在NDC(归一化坐标系)里做的,在观察变换、透视变换之后。我选择比较早实现这个函数,是因为这个算法是相对独立的,只要输入屏幕空间的点,和屏幕的裁剪边界,就能验证这个算法是不是实现正确了。我们可以用DDA画线框,来可视化我们的裁剪结果。在后面我们要在NDC里裁剪时,只需要输入NDC空间的点,以及[-1,1]这样的边界。

5)实现一些数学基础类

这里主要指的Vector3Matrix4x4。这里我的经验是,只实现自己真正使用到的函数。这两个类我都是使用的POD(plain old data)类型,简单、高效、栈分配内存、无构造函数。只需要实现基本运算,这里矩阵我实现了转置(transpose)和求逆操作(inverse),后面根本没用到。 这里需要注意的是矩阵是行主序(Row Major)还是列主序(Column Major),二者都可,但是影响几种变换矩阵的书写。Vector的叉乘,在左手系、右手系里公式是一样的,无影响。

6)实现相机类,以及几种变换矩阵

相机类里需要维护观察矩阵V、投影矩阵P、视口变换矩阵。还有模型变换矩阵M(transform、rotate、scale),一般维护在场景中或者其他持久化的系统中(如Unity的Prefab),这个我们这里不实现了。 这里要注意的是,左手系和右手系的变换矩阵的公式是有区别的(上面说过,行主序和列主序的公式也不同),抄公式可能会抄错,所以自己推导最好。 这一步还是比较关键,实现之后,应该验证一下,例如在原点放一个1x1x1的立方体,相机放在稍远处,然后用线框画出来,看看是不是和自己期望的一致。 相机类还应实现旋转、进退的功能,这样就能从不同角度、位置观察模型,也方便验证我们的裁剪是不是正确

7)实现顶点类,以及加载模型

一个重要的概念,顶点≠顶点位置,这个很多人容易混淆。顶点除了位置,一般还包括纹理坐标、法向量等。 加载模型我使用了也是比较流行的tinyObjLoader,虽然我们也可以自己去撸.obj文件的读取(其实还挺好读),但是这种工作不是我们的目的,所以我们选择直接用tinyObjLoader。 我们用Mesh类来管理读取到的三角形,然后用线框画出来,验证一下。我读的模型大概2000面,这一步已经开始对性能有挑战了。 在后面我们还要用到顶点的法向量(光照要用到),有的.obj文件带这个信息,有的不带,所以这里我统一自己计算法向量(v1v0 cross v2v0) 我们还要读取纹理坐标到顶点中,后面的纹理映射要用到。

8)实现三角形填充算法

上一步我们能得到模型的线框视图,这一步之后我们能得到白模。 我使用的是BaryCentric三角形填充算法。它的原理是计算像素点在质心坐标系的位置,从而判断像素点是不是在三角形内部。其他的也可以用扫描线算法来填充三角形。 使用BaryCentric算法方便的一点是,有了质心坐标,我们还可以很方便的进行纹理插值,这个后面说。 如果用同一种颜色填充,那看起来就是白色的一篇。 其实这里我们已经相当于是在做pixel shader做的事情,就是给具体的像素着色。我们可以实现Half-Lambert算法,在环境中设置一束平行光,通过顶点的法向量来计算光照。这样我们就能得到明暗变化的白模了。

9)实现纹理映射

首先是读取纹理,我引入lodepng来读取png图片。 纹理映射很简单,也就是通过顶点里存的s、t这种归一化的值,来确定像素颜色。 需要注意的是,我们只有顶点的纹理坐标,我们要怎么得到光栅化的点的纹理坐标呢?当然是插值。插值这里有一个trick,就是我们不能用上面得到的质心坐标,因为它是透视不正确的(带一个1/w)。也就是说,纹理在世界空间是线性变化的,在屏幕空间,是1/w线性的。如果我们在1/w空间进行插值,就能得到透视正确的纹理了。我们在顶点中储存texcoord / w1/w两个变量,用它们去插值,然后相除得到最后的纹理坐标。

3. 未来可以继续迭代的内容

  • 优化帧率(SIDM、多线程)
  • 目前我们的着色是unlit的,可以加入简单的光照模型
  • 加入场景管理,实现模型变换。
  • 加入Alpha Test和Blend
  • 实现deferred rendering
  • 用OpenGL接口替换我们的接口,逐渐变成一个学习OpenGL的项目…