【乐府】撸起袖子造轮子: 软件渲染器

撸起袖子造轮子: 软件渲染器

(公司最近在招cocos资深开发/unity资深开发/unity图形程序员, 有兴趣的可以直接拉到文章末尾, 有招聘相关信息)

一. 为什么要写一个软件渲染器

简单来讲就是为了更深入的理解渲染管线。

我们平时在使用Unity/Cocos/Unreal 这些引擎开发游戏的时候, 会用到很多引擎的图形相关API,这些引擎的接口虽然都各不相同,但是在更底层一点的地方, 它们毫无例外都是使用opengl/d3d或者其他类似的图形接口去驱动显卡绘制图形。

我们通过opengl 喂很多三角形数据和其他相关数据给显卡, 显卡就可以渲染出一张2D的图片, 这中间究竟发生了事情?

如果让我们暂时忘记opengl等等这些别人已经造好的轮子, 给你同样的数据, 你能否造出一个轮子来渲染出这张图片?

在编程领域, 我们习惯了使用各种轮子, 然而有一条实践经验是我认为值得推荐给大家的, 就是

想要深度理解一个轮子, 就试着造一个轮子吧

即使你造出来的轮子非常简陋, 它也有价值。

这个轮子, 就是我们本文中会提到的软件渲染器

在CPU层面, 用纯应用层代码的方式, 对输入的三角形数据进行光栅化, 着色, 这就是最简单的软件渲染器。

二. 我造的小轮子:toy-raster

下面是我在工作之余写的一个软件渲染器

https://github.com/laomoi/toy-raster

核心渲染代码(src/core)不超过1000行, 目前渲染效果图如下:

(感谢tinyrenderer项目提供的可免费使用的模型数据)

虽然这个软件渲染器仅仅是一个玩具, 但是它实现了一些最核心的功能:

  • 三角形光栅化
  • 透视贴图
  • 视锥裁剪
  • 边缘剔除
  • 深度缓冲
  • 纹理双线性插值
  • 2x2的MSAA
  • 可灵活定制顶点着色/片元着色, 提供接口和vary插值列表

在示例里, 可以完全定制自己的顶点着色器和片元着色器, 可以给它加一些光照模型之类的, 做出更多的效果。

大家知道在游戏里调试shader其实还是挺麻烦的, 着色器的调试手段比较有限, 但是如果在自己写的渲染器里, 所有代码都是纯CPU执行的, 你可以在任意一个片元渲染的时候加上断点或者打上日志, 这个或许是我们学习开发这个渲染器一个额外的收获, 可以用来调试一些图形效果:D

三. 怎么去写软件渲染器

在这篇文章里, 我会把软件渲染器的一些关键点讲清楚, 在文章后面也会给出一些参考资料的链接,供读者进一步学习。

强烈建议刚入门计算机图形学的读者去B站看一遍闫令琪老师的<现代计算机图形学入门>的前9课, 本文中有部分图片也是截图自这个课程。

文中引用资料和部分截图还来自于这2本书, 一本是虎书, 指的是《Fundamentals of Computer Graphics》, 这是国外很多高校的图形学教科书;

另外一本是RTR, 指的是《Real-Time Rendering》 4th 这本书, 这也是计算机图形界的九阴真经, 这2本书可能都可以在网上下载到电子版(请大家支持正版图书:D)。

最后, 建议读者可以亲手自己从0开始写一个类似的渲染器, 因为纸上得来终觉浅, 绝知此事须躬行

四. 语言选择

在网上可以搜到不少很多大佬在学习图形学过程中写的软件渲染器, 不过其中大部分都是C++的, 如果仅仅是为了学习原理, 不考虑太多性能上的问题, 我们可以使用更容易使用的Javascript, 为了更容易工程化管理, 我在toy-raster中使用Typescript

开发环境也很简单, 安装一下nodejs, tsc即可。

五. 渲染三角形

对于单个三角形的渲染, 我们要做的事情就2件, 三角形光栅化成多个像素 + 对像素着色

1. 什么是三角形光栅化(Triangle Rasterization)

我们假设在2D屏幕上有一个三角形, 三角形3个顶点位置已知, 屏幕中每一个像素格子是着色的最小单位, 那么三角形光栅化的过程就是 判断屏幕中哪些像素格子是属于三角形的范围, 给这些格子填上三角形的颜色。

如下图所示:

这里的算法关键就是, 给定屏幕上的某个像素, 如何判断这个像素点处于三角形内部?

2. 通过向量叉乘判断是否在三角形内部

如下图所示:

这里有个比较简单的判断方法,如果满足以下3个条件则说明点Q在三角形P0P1P2的内部:

  1. 点Q在直线P1P2的左边
  2. 点Q在直线P0P1的左边
  3. 点Q在直线P2P0的左边

那么如何判断点是否在直线的某一侧呢? 答案是进行向量叉乘

我们知道2个向量进行叉乘的话, 结果会得到一个新的向量, 这个向量垂直于2个向量所在平面, 指向的方向我们选择使用右手法则进行判断。

如果我们用向量P1P2 叉乘 向量 P1Q, 如果得到的向量方向为正, 则说明点Q在P1P2的左边

同理, 我们继续用P2P1 叉乘 P0Q, 用向量P2P0 叉乘 P2Q, 来判断点Q在直线的哪一侧

在图中例子中, 向量P2P0 叉乘 P2Q 得到的向量方向跟其他2个向量方向不一样, 证明了Q不在三角形的内部

3. 通过计算重心坐标判断是否在三角形内部

在我们的渲染器代码里, 我们用的是另外一个方法, 就是计算出这个像素点在这个三角形内的重心坐标(barycentric coordinate),

重心坐标 (w1, w2, w3)通常写成(α, β, γ) , 当某个像素点位于三角形内部时, 它的重心坐标的3个取值范围在[0, 1], 如果不在这个范围内, 说明不在三角形内。

重心坐标的计算方法就是求面积比, 比如图中, 三角形三个顶点跟P点进行连线,则得到了3个小三角形, 我们涂上不同的颜色, 可以知道:

α = 粉红色的三角形面积 / 总面积

β = 黄色的小三角形面积 / 总面积

γ = 蓝色的小三角形面积 / 总面积

求面积的方法也很简单, 通过向量叉乘即可, 如果偷懒, 也可以直接从虎书上抄一下最终的计算公式, 这里就不贴出详细公式了。

重心坐标, 不仅可以用来判断像素点是否在三角形内,还能利用这个重心坐标进行插值, 比如我们三角形的3个顶点的颜色是C1, C2, C3, 那么这个像素点的颜色可以使用插值得到Color = C1α + C2β + C3*γ

不仅是颜色,包括顶点的其他属性I, 只要I在空间中随着坐标的变化也是线性变化的, 都可以使用这个插值公式:

我们用一段伪代码来写这个2D平面三角形的光栅化:


for (let x=0; x<screenX; x++) {
    for (let y=0; y<screenY; y++) {
        let (α, β, γ) = 计算重心坐标(P0, P1, P2, x, y)
        if (α > 0 && β > 0 && γ > 0) {
            //inside triangle
            shading(x, y, α, β, γ)
        }
    }
}

以上代码会遍历整个屏幕空间,对每一个像素点进行判断, 会比较浪费性能, 简单的计算一下三角形的bounding box, 可以缩小遍历的范围

4. 三角形边缘着色(edge detect)

在上面伪代码里, 我们只考虑了 α, β, γ都是在三角形内部的情况, 如果目标像素点是在三角形的边上(α, β, γ 中存在取值为0的情况),

那么是否要对这个像素点进行着色呢? 这个地方并没有绝对的规则.

考虑如下图的情况:

2个三角形有公共边, 三角形的剩下的顶点正好在公共边的两侧

我们可以选择永远对边缘着色, 存在的问题是边缘会被画2遍。

虎书上也介绍了一种简单的边缘着色算法, 可以简单判断这个边缘应该属于哪个三角形:

假定屏幕外有一个固定点S(-1, -1), a点和b点中,哪个点跟这个S点同处于公共边的一侧,则认为这个边缘应该归属哪个三角形。

这也是toy-raster中对于边缘着色采用的算法。

5. 3D世界里的三角形的光栅化

上面我们提到的一直是2D屏幕空间上的三角形如何光栅化成像素, 那么定义在3D空间里的三角形,该如何进行光栅化呢?

其实很简单, 我们先把3D空间里的三角形顶点投影到屏幕空间上, 后面就变成了2D屏幕空间上的光栅化了。

这整个的投影过程我们可以简单描述一下:

  1. 3D空间中的三角形的顶点坐标一般是模型的本地坐标或者世界坐标,
  2. 我们先通过矩阵变换得到三角形顶点在摄像机下的观察(view)坐标,
  3. 再通过正交或者透视投影(projection), 把观察坐标转化到归一化的齐次坐标来实现投影过程
  4. 最终还要经过屏幕的视口映射, 把三角形顶点坐标转成[0, screenSize]的范围内。

整个的变换过程涉及的数学主要是矩阵乘法, 投影矩阵, 还有齐次坐标的概念,这个建议可以看一下虎书中的第5-7章内容。

(建议读者可以自己推导一下投影矩阵, 可以加深对这块的理解)

6. 纹理贴图

当我们完成了对一个3D三角形的光栅化之后, 如前面所提, 我们通过三角形顶点属性(颜色)的插值, 可以得到三角形内部某个像素点的颜色,

这样就可以对这个像素进行着色了。

但是, 如果我们想得到一个更丰富的色彩效果, 一般我们会给这个三角形贴一个纹理, 每个三角形顶点定义了一个uv坐标, 表示这个顶点在纹理上的采样坐标.

三角形内部可以使用重心坐标对顶点的uv值在三角形内部进行插值, 从而得到三角形内部所有像素点的uv坐标, 这样就可以在三角形上渲染出如下的效果:

7. 模型渲染

模型 = 定义了很多个三角形 + 定义了三角形的顶点属性(坐标, 法线, uv) + 需要用到的纹理贴图

模型的渲染其实就是把模型每一个三角形都渲染一遍就可以了, 因为三角形之间有远近遮挡关系, 所以需要再加一个深度缓冲区,

它就像一张图, 记录了当前屏幕中已经着色过的像素的z值, 当前片元要着色之前, 需要比较一下z值, 如果新的片元其实距离屏幕更远, 那么就放弃着色, 否则就进行着色, 并更新深度缓冲区里的值。

因为在顶点的投影过程中其实我们已经计算了 1/z的值, 而且因为希望在z比较靠近屏幕的地方有更高的精度, 所以我们通常实际存储在深度缓冲区里的值用的是1/z

在文章开头大家看到的渲染模型的效果, 它在片元着色器里加入了一些光照效果(漫反射+高光), 所以看上去立体感比较强, 同时还加入了一张法线贴图来增加细节, 只是在着色时需要额外多采样一张贴图, 它没有增加模型的面数却带来了不错的效果。

光照的渲染方程不在本文的讨论范围, 大家可以自行查阅虎书或者RTR。

可以给大家看一下对比图(从左到右为: 普通漫反射, 增加法线贴图, 增加高光贴图):

普通漫反射中用的法线是利用三角形3个顶点的法线插值而来

增加法线贴图之后, 用的法线是从贴图中采样而来 (绝大部分情况下, 法线贴图存储的法线方向都是切线空间下的向量, toy-raster中使用的暂时是模型空间下的法线贴图)

高光贴图可以更细的定义纹理上每个像素点的高光系数, 可以带来更好的高光效果。

这几张贴图放在toy-raster/res/目录下

7. 透视校正

我们定义一个最简单的模型,一个立方体, 它由6个面构成, 每个面是2个三角形。

给它的每个顶点都定义好uv坐标,

我们使用上面提到的算法, 写出来的渲染器, 渲染出来的效果大概是这样的:

我们发现每个面感觉都是凹进去的, 这里的问题在于我们进行了错误的uv坐标插值

我们假设空间三角形三个顶点具备属性I1,I2,I3(属性可以是uv或者其他), 三个顶点的深度为z1,z2,z3, 三角形内某个点的深度为z, 投影到2D平面后, 重心坐标为(α, β, γ)

回忆一下我们的渲染流程:

  1. 输入3D三角形的三个顶点
  2. 顶点投影到2D屏幕空间
  3. 三角形光栅化, 同时计算每个像素在2D三角形里的重心坐标
  4. 三角形内部每一个像素使用重心坐标进行插值(uv), 然后使用uv进行采样和着色

在第4步里插值出来的uv坐标为:

(α, β, γ)是2D平面上算出来的重心坐标, 而I值是3D空间上的值, 3D空间上的线性关系投影到2D空间上就不一定是线性了, 除非三角形3个顶点的z是一样的。

所以我们需要修正I的插值的计算方法,因为这个问题是透视投影带来的, 我们把这个修正叫透视校正(perspective correction)

透视校正的详细资料可以查阅<3D游戏中与计算机图形学中的数学方法> 5.4, 或者参考这篇文章图形学基础之透视校正插值

这里我们只需要知道一个性质就可以:

空间三角形投影到在2D的屏幕空间中之后, 1/z是线性变化的, 在空间中跟三角形的坐标成线性变化的属性I(比如uv), 投影到了2D屏幕空间之后,也是关于1/z线性变化的。

则按照上述性质我们有以下的插值公式:

我们可以把公式调整一下, 改写成:

大家会发现, 正确的插值公式跟原来错误的插值公式本质上是一样的,只是重心坐标的取值 从 (α, β, γ) 变化成了

我们可以认为这个就是校正过后的重心坐标, 也就是该点对应在原来3D空间中的真实重心坐标

我们使用这个校正后的重心坐标对三角形顶点的各种属性进行插值就可以得到正确的值了。

8. 更好的渲染效果: 纹理双线性插值(Bilinear interpolation)

我们插值出了uv坐标之后,直接拿这个uv坐标去纹理图片上采样的话, 这个坐标不一定就是某个像素点的正中心,

这个时候我们有2种选择,

一种是nearest, 也就是选择距离最近的像素点颜色, 这样采样方法在很多时候会造成锯齿,

还有一种是bilinear, 也就是取这个采样点周边4个像素的颜色, 然后根据距离不同做插值得到最终的颜色。

下图是双线性插值的简单图解:

采样P点时, 距离它最近的4个像素点中心是Q11, Q21, Q22, Q12, 先对上面的Q12,Q22插值得到R2, 对下面的Q11, Q12插值得到R1,

再对R1, R2在P点插值即可。

下面是使用不同的纹理采样方式产生的不同效果(上图是nearest, 下图是bilinear):

当纹理的分辨率比较高, 远大于采样时的像素距离时, 要提高渲染效果一般是采用多级的mipmap纹理, 也就是根据原纹理生成多个小分辨率的纹理, 根据不同的距离选择不同的纹理, 甚至还可以在2个纹理之间插值。

关于mipmap目前toy-raster还没有实现, 有兴趣的读者可以自行添加这个功能。

9. 更好的渲染效果: MSAA 抗锯齿

按照我们上面说的使用重心坐标对三角形进行光栅化, 当判断像素点中心位于三角形内部时, 就对该像素进行着色, 那么这个三角形在光栅化之后的样子其实是这样的:

1) 产生锯齿(走样)的原因

其实我们期望光栅化出来的是一个边缘比较平滑的三角形, 而不是这种边缘都是锯齿的, 但是因为屏幕分辨率所限,这种锯齿在所难免。

对于产生的这种锯齿我们通常把它叫做走样 (alias)

我们可以看一下三角形的其中一条边, 光栅化之前它应该是一条直线, 但是因为我们光栅化使用的最小单位是像素宽度, 我们光栅化的过程其实就以像素为单位对直线做采样。

从数字信号的角度看, 采样的频率跟不上信号变化的频率时, 就会发生走样

2) 解决走样问题

解决这种问题一般就2个方法:

A. 加大采样频率

如果我们的屏幕分辨率足够大, 像素单位非常小,那么我们这样采样出来产生的锯齿就不会这么明显。

但是大部分情况下我们不一定会有足够高分辨率的屏幕, 而且高分辨率的采样, 带来的成本也是巨大的, 因为每一个像素都需要执行一次片元着色器程序。

B. 反走样(anti-alias, 缩写AA)

反走样的方法有很多种,最经典的方法就是MSAA(multiple-sampling anti-alias)。

MSAA, 顾名思义,就是增加采样点, 但是要注意, 增加采样点并不是增加着色点, 对于一个像素, 我们原本只采样它一个中心点,来判断是否在三角形内,

现在我们不采样这个中心点,改为采样这个像素内的多个点。

假设采样了4个点, 有3个点是在三角形内, 我们可以近似认为三角形占据了这个像素75%的面积,

我们继续使用这个像素的中心点位置进行一次着色(执行片元着色器), 得到颜色后我们乘以75%填入像素即可。

这个做法, 从数字信号的角度解释的话, 就是先对信号做模糊(filter), 过滤掉信号中的高频部分, 再做采样

这多个采样点的选择也是有讲究的, RTR书中提供了多种不同的采样点的效果比较:

在toy-raster中我们使用了2X2 RGSS的采样点进行了MSAA, 要注意的是, 启用了2X2 MSAA之后, 深度缓冲和颜色缓冲区都得变成原来的4倍。

下面是对比图(上图是不使用MSAA, 下图使用了2X2的MSAA):

10.最后就是我们最重要的招聘信息了:D

乐府互娱是9102年成立的的一家非常年轻的游戏公司, 位于上海目前游戏新秀公司扎堆的漕河泾开发区, 有兴趣进一步了解的可以点开我们的官网

我们目前在招聘的程序岗位有 unity资深开发/cocos资深开发/图形程序员/golang游戏开发/golang平台开发

公司的年轻也意味着更大的成长空间和更高的升值潜力, 欢迎跟我们的HR小姐姐联系: wangyl@lovengame.com

同时也欢迎大家关注我们的技术公众号:

60赞

强啊 牛批

慢了一步,沙发没了

好东西, 早想了解了

给大佬倒茶:tea:

乐府的帖子真是帖帖精品,我喜欢:+1:

:joy: 又可以看很久了

给大佬递茶

乐府出品, 先赞在看

哈哈厉害! 我也看了闫老师的视频,可至今还没能写出渲染器来:joy:

太厉害了。 。。。。。

给大佬点赞:+1:

给大佬摇扇子

给大佬递茶!牛逼

给大佬倒橙汁!牛逼

牛逼 得看好一会了

我一直不了解什么叫软件渲染器,,,现在大概知道了。
话说判断一个点是不是在三角形内,为啥我觉得是判断三角形落在那个四边形内(像素格) 呢? 我用的笨办法,算的斜率哈哈哈哈

MARK5

流批(破音~)

大佬牛逼:3: