开篇

笔者作为一个从13年才开始进入游戏行业的从业者,游戏职业生涯基本都是跟着手游项目的来积累技术,经历过Cocos2d-x到Unity, 现在面临这Unreal的选择。相信有不少朋友也会有和我类似的成长轨迹,加上一直在中小公司工作更多面临的是无尽的版本开发以及问题解决,很少有专门的时间停下来去了解引擎背后的很多原理。所以希望从这个系列开始把自己平时学习的知识完整串联记录起来, 尽量形成一个完整的知识体系,也希望能对其他有类似迷惑的人有帮助。

申明和必要性

由于完整的3D游戏引擎是一个非常庞大的工程, 涉及的模块及工具链也非常复杂。靠本人微薄的技术积累很难讲清讲全,只能尽量把学习过的模块尽量讲清楚。后续系列基本会基于C++、Win32、OpenGL等方向进行代码实现。
可能会有人会质疑是否有必要去学习一些偏封装底层的东西,这个就见仁见智了。就像你会开车但是不一定要求你必须懂修车, 但是你懂修车遇到路上抛锚是不是可以快点定位, 小毛小病也可以自己解决掉。游戏客户端开发大体分工也分GamePlay和Engine 两个方向, 是不同的方向没有优劣, 任何一个方向做精一样会取得不错的成就。

渲染管线流程

Alt text

流程 描述
Vertex Data 这一步主要是准备好顶点数据, 这一步就是OpenGL中的VAO VBO的初始化工作。
Primitive Porcessing 顶点数据根据图元类型进行预处理, 例如到底 GL_LINES, GL_LINE_STRIP, GL_TRIANGLES, GL_TRIANGLE_STRIP, GL_TRIANGLE_FAN.
Vertex Shader 顶点着色器执行每个顶点的处理, 根据接收的顶点数据信息进行一些转换, 例如MVP坐标系的转换。
Tessellation 曲面细分, 是一个可选项。将多边形顶点细分成更多细小的碎片,提高逼真度。一般和Displacement Mapping配合使用, 详见这里
Geometry Shader OpenGL 3.0引入的新着色器, 主要用于将Vertex Shader输入的可调整的图元进行加工生成更多形状的顶点.
Primitive Assembly 图元装配主要用于讲上述输入的顶点组装成点、直线, 三角形的过程.
Rasterization 就是将上个步骤中的图元分解成片段的的过程(像素的前身)
Fragment Shader 片段着色器是可编程阶段, 用于改变即将生成的像素的颜色和深度信息.
Pre-FrameBuffer 这一步主要进行一些像素处理, 包括裁剪, 模版测试, 深度测试等, 经过一系列操作最终输出到屏幕上.

3D渲染流程

上述渲染管线包含了大致渲染的完整流程, 涉及的技术点很多。千万不要慌,先做个大致的了解做大心中有数。 下面我们开始来做减法,
上述流程我们先做个最简抽象, 基本的流程应该是:顶点数据->经过一系列转换->显示到屏幕上。当然不可能这么简单, 我们稍微加点难度
Alt text

顶点数据->世界坐标系->观察坐标系->裁剪坐标系->规范化坐标系->屏幕坐标系。
有这几个流程我们基本能简单在屏幕上显示最基本的3D单元拉, 流程大致是这样,这个流程我们一般称之为软件光栅化过程, 那我们开始编码准备:
要实现一个3D软件光栅化的大致技术点分解如下:
1. 单点像素的绘制
2. 矩阵及基本运算的支持
3. 透视摄像机的实现
4. 绘制直线和三角形
5. FrameBuffer和Zbuffer的实现
6. 屏幕坐标系
7. 纹理UV映射

1. 单点像素的绘制

选择的是稍微熟悉的win32的平台, 我们第一个会想到SetPixel, DrawLine之类的函数, 这是win32下GUI中常用的一些函数, 测试下来直接使用SetPixel速度太慢, 而且考虑到我们要比较自由的做屏幕内存的刷新,可能得用其他方式。最好的方式是我们自己申请一块内存的buffer按照我们的规则绘制之后一次性再往屏幕上刷新。 内存设备接口CreateCompatibleDC便成了我们很好的选择, 详细可以参考这里的介绍.

2. 矩阵及基本运算

用c++去实现基本的矩阵, 这块主要是旋转的几个公式需要注意差别, 最好尝试在纸上推倒下. MSDN官方链接看https://msdn.microsoft.com/en-us/library/windows/desktop/bb206269(v=vs.85).aspx

3. 透视摄像机的实现

如果有了物体那自然少不了观察者, 这里我们需要实现一个摄像机。正交摄像机相对比较简单,我们这里实现一个透视摄像机。
这里要注意, OpenGL是右手坐标系, DirectX是左手坐标系, 对这里计算公式也会有影响, 这里有详细的推导过程, 大家可以尝试跟着看一遍。当然最后的结果就是摄像机这里我们要生成两个矩阵, ViewMatrix和ProjectionMatrix。
Q: 为什么需要ViewMatrix ?
A: 在世界坐标系中, 摄像机不一定就位于世界坐标系的原点, 而且摄像机的观察方向也不一定是正视Z的正方向。这个ViewMatrix是旋转摄像机还是旋转其他物体相对摄像机的位置 ?

Q: 如何实现ViewMatrix ?
A: 参考这里, OpenGL和DirectX的ViewMatrix公式不一样.

Q: 为什么需要ProjectionMatrix ?
A: 透视摄像机是一套既有的规则的存在, 也就是我们平常经常说的近大远小。这是一个视椎体,我们需要讲所有物体换算到他的范围内去。
Alt text

Q:如何实现ProjectionMatrix ?
A: 参考这里, 记住OpenGL和DirectX的PorjectionMatrix公式也不一样.

4. 绘制直线和三角形

这里主要技术点在于直线绘制的算法以及三角形填充的算法, 常用的支线绘制算法有Bresenham快速直线算法, 以及三角形填充算法当然你也可以自己尝试去按照自己的想法去写写看, 然后再考虑效率问题。
Alt text

5. FrameBuffer和Zbuffer的实现

这里的实现相对简单点, FrameBuffer就是我们开始的时候需要Malloc的一块内存区域, 绘制好之后动态刷新到屏幕上去. Zbuffer相对简单点, 只要有一个和屏幕大小一样的buffer, 来记录下当前的每个像素的深度值, 按照z的大小来决定是否写入FrameBuffer.

6. 屏幕坐标系

我们绘制的物体由局部坐标下->世界坐标系->视口坐标系->投影坐标系转换之后, 需要往屏幕坐标系上转换, 毕竟3D空间上的世界再精彩, 我们最后绘制像素在一个2D屏幕上绘制的, 这里就涉及3D往2D的转换.
Vector worldPos;
Vector screenPos;
screenPos.x = (worldPos.x/worldPos.w + 1)SCREEN_WIDTH/2;
screenPos.x = (1 - worldPos.y/worldPos.w)
SCREEN_HEIGHT/2;
screenPos.z = screenPos.z / screenPos.w;

到这一步我们已经能看到在屏幕上绘制的立方体了, 当然可能是纯色的. 或者每个面只是一个单色的, 下面就要去给每个面蒙一块画布~

7. 纹理UV映射

Alt text

这一步开始的时候我没意识到, 发现完全不是我想象的近大远小的样子嘛 ! 比如是这样的:
Alt text

其实这是一个典型的案例, 详细看这里wiki介绍:

Alt text

简单的说就是这里的技术点叫Perspective correctness. 我们mesh上的UV分步的时候时候不能简单的来做线性的变化, 而应该考虑每个像素的Z来做采样, 最后的结果当时最后一个图是正解.

总结

最后终于到展示最后成果的时候了, 真是:红军不怕远征难, 最后结果看丑男~ 还不是很完美, 也还有很多技术点没讲解清楚, 不过还是很开心能完整把流程做完😄

Alt text

代码实现

https://github.com/zpzsoft/SoftRaster

主要参考链接

http://www.songho.ca/opengl/index.html
https://github.com/skywind3000/mini3d