实现个简单的固定渲染管线软渲染器不算复杂,差不多700行代码就可以搞定了。之所以很多人用 D3D用的很熟,写软渲染却坑坑洼洼,主要是现在大部分讲图形的书,讲到透视投影时就是分析一下透视变换矩阵如何生成,顶点如何计算就跳到其他讲模型或者光照的部分了。
因为今天基本上是直接用 D3D 或者 OGL,真正光栅化的部分不了解也不影响使用,所以大部分教材都直接跳过了一大段,摄像机坐标系如何转换?三角形如何生成?CVV边缘如何检测?四维坐标如何裁剪?边缘及步长如何计算?扫描线该如何绘制?透视纹理映射具体代码该怎么写?framebuffer zbuffer 到底该怎么用?z-test 到底是该 test z 还是 w 还是 1/z 还是 1/w ?这些都没讲。
早年培训学生时候,我花两天时间写的一个 DEMO,今天拿出来重新调整注释一下,性能和功能当然比不过高大上的软件渲染器。但一般来讲,工程类项目代码不容易阅读,太多边界情况和太多细节优化容易让初学者迷失,这个 mini3d 的项目不做任何优化,主要目的就是为了突出主干:
源代码:skywind3000/mini3d · GitHub
可执行:https://github.com/skywind3000/mini3d/releases
操作方式:左右键旋转,前后键前进后退,空格键切换模式,ESC 退出。
特性介绍
- 单个文件:源代码只有一个 mini3d.c,单个文件实现所有内容,阅读容易;
- 独立编译:没有任何第三方库依赖,没有复杂的工程目录;
- 模型标准:标准 D3D 坐标模型,左手系 + WORLD/VIEW/PROJECTION 三矩阵;
- 实现裁剪:简单 CVV 裁剪;
- 纹理支持:最大支持 1024 x 1024 的纹理;
- 深度缓存:使用深度缓存判断图像前后;
- 边缘计算:精确的多边形边缘覆盖计算;
- 透视贴图:透视纹理映射以及透视色彩填充;
- 实现精简:渲染部分只有 700行, 模块清晰,主干突出;
- 详细注释:主要代码详细注释。
如何编译
- mingw: gcc -O3 mini3d.c -o mini3d.exe -lgdi32
- msvc: cl -O2 -nologo mini3d.c
- 已编译版本: https://github.com/skywind3000/mini3d/releases
截图演示
纹理填充:RENDER_STATE_TEXTURE
色彩填充:RENDER_STATE_COLOR
线框绘制:RENDER_STATE_WIREFRAME
增加光照和二次线性插值(朋友给 Mini3D 增加的光照效果截图)
阅读要求
- 看过并了解 D3D / OGL的矩阵变换。
- 用 D3D / OGL 完成过简单程序。
实现说明
- transform:实现坐标变换,和书本手册同
- vertex: 如何定义顶点?如何定义边?如何定义扫描线?如何定义渲染主体(trapezoid)?
- device: 设备,如何 projection,如何裁剪和归一化,如何切分三角形,如何顶点排序?
- trapezoid:如何生成 trape,如何生成边,如何计算步长,如何计算扫描线
- scanline:如何绘制扫描线,如何透视纠正,如何使用深度缓存,如何绘制?
基础练习:先前给学生的作业
- 增加背面剔
- 增加简单光照
- 提供更多渲染模式
- 实现二次线性差值的纹理读取
扩展练习:给有余力的学生
- 推导并证明程序中用到的所有几何知识
- 优化顶点计算性能
- 优化 draw_scanline 性能
- 从 BMP/TGA 文件加载纹理
- 载入 BSP 场景并实现漫游
原理讲解
其他内容
当年还用不了 D3D 和 OGL ,开发游戏,做图形实现软件渲染是必备技能,当年机型差,连浮点数都用不了,要用定点数来计算,矩阵稍不注意就越界了。计算透视纠正还是一个比较昂贵的工作,更多游戏使用仿射纹理绘制,只是把离屏幕近的多边形切割成更小的三角形,让人看起来没有那么明显。即便到了 Quake 年代,计算 1/z 的除法也只是四个点才算一次(经过精确计算CPU周期,绘制四个点时下一个点的 1/z刚好算完),Quake 的四个点内也还是仿射纹理绘制……
那时显卡没普及,光软件渲染器的优化就是一个无底洞,今天有了 OGL/D3D 和显卡,人的精力才能充分集中在更高层次的场景组织、层次细节、动态光照等功能上。然而有空的时候,花个一周时间坐下来了解一下这部分的大概原理,推导所用到的数学模型,也能帮助大家更好的理解底层运行机制,写出更加优化的代码来。
PS:光线跟踪版本的软件渲染,考虑光照的话,简单实现起来差不多 500 行,比这个要简单一些。各位有兴趣也可以尝试一下,就是简单渲染个立方体足够了。
帅哥,你这个真的是业界良心。不过可以不可以写篇文章配合代码讲解讲解原理性的东西腻
@chris
看懂这个之前先要熟悉 D3D 的基本原理呀,我这个注释已经满详细的了嘛,如果需要,等我有空再整理下吧。
很好的教程,希望楼主有空再做个详细点的解释哦,照顾一下没看过矩阵变换的同学:-)
嗯,D3D有大量教程,进行这个课程前,起码要用D3D写过旋转立方体这样简单的DEMO,并且弄清楚背后的数学啊。
@IceCoffee
我把d3d涉及 的矩阵知识学了一遍,知道了相机具体存在的形式,旋转,平移,缩放,投影。
现停在 扫描线和帧缓存这里 ,没有完全理解扫描线是什么
帅哥有时间 搞一个稍微详细的图,描述下渲染的执行过程o(∩_∩)o
你好。
有点问题想请教一下。深度测试那里,为什么是用rhw来做,而不是z?
@Shihira Fung
实际是w缓存,经过projection矩阵乘法后,w和z是成线性关系的(具体见透视投影矩阵生成),固定管线标准模型中,都是用w进行缓存的,而rhw是保存着1/w的值,在具体绘制的时候才会计算 w = 1/rhw,除法代价大,因此直接用1/w进行判断,越远的点rhw值越小,越近的点,rhw值越大,所以深度缓存中rhw越大的值覆盖越小的值,因为他们代表越近的像素点。这样清空深度缓存时填写0就行了。不需要笨拙的用传统的z判断,初始化成一个很大的值,每次判断更小的值被保留下来。
博主,请教一个问题。
假如我要给Mini3D添加光照,而且不是Gourand是Phong,也就是要给每个pixel计算法向量。因为每个pixel是在线性插值的时候计算出来的,这个时候pixel的坐标x和y分量是在屏幕空间中的,但是Phong光照模型计算需要计算入射向量和观察点到该点的向量,光源坐标和光差点坐标是在世界空间的,这样来怎么计算入射向量和观察点到该点的向量呢
请教博主一个问题:
假如我要给Mini3D添加Phong光照。Phong光照需要计算每一个pixel的入射光向量和观察点到pixel的向量,但是由于在求pixel的时候已经是在屏幕空间中进行线性插值了,这个时候求出来的x和y分量不能用来求入射光向量和观察点到该点的向量,该怎么办呢?
@Christal
你想做phong像素光照的话实际上是这样一个过程:
顶点程序中保存转换到世界空间的法线(如何你打算在世界空间计算光照的话),保存世界空间顶点位置,传递到片段程序。
片段程序中利用顶点传递的法线和顶点位置进行光照,这样光照还是在世界空间进行的呀,放到顶点里面计算是为了让法线得到透视校正插值,这样的光照效果更加精确。
@Christal
很简单,你需要将三维空间的 x,y,z 一起放入插值,即(一开始/w),进行屏幕空间插值,插值完后再* w。这个问题我新文章里面有解释:http://www.skywind.me/blog/archives/1828 类似 OpenGL的 varying 的标准插值方法,希望能帮助到你,给 mini3d 添加 varying 很简单。
博主,请教一个问题。
我看在扫描线绘制的时候,顶点属性进行了透视矫正插值(z(b1/z1 + t(b2/z2 – b1/z1))),但是在生成水平扫描线时,扫描线的左右两端点属性是直接线性插值的结果(b1 + (1 – t)b2)),没有进行透视矫正。不知道是不是我的理解有问题,希望博主指点一下。
博主,请教一个问题。
我看在扫描线绘制的时候,顶点属性进行了透视矫正插值(z(b1/z1 + t(b2/z2 – b1/z1))),但是在生成水平扫描线时,扫描线的左右两端点属性是直接线性插值的结果(b1 + (1 – t)b2)),没有进行透视矫正。不知道是不是我的理解有问题,希望博主指点一下。
我看在扫描线绘制的时候,顶点属性进行了透视矫正插值(z(b1/z1 + t(b2/z2 – b1/z1))),但是在生成水平扫描线时,扫描线的左右两端点属性是直接线性插值的结果(b1 + (1 – t)b2)),没有进行透视矫正。不知道是不是我的理解有问题,希望博主指点一下。
Pingback: 用C#实现一个简易的软件光栅化渲染器 - 算法网