从模型到图形——我的实时渲染管线记录

在此回顾记录2019年12月实习期间编写Soft Renderer时的思考。


主要模块


以下为我实现Soft Renderer时划分的主要模块

  • 坐标变换MVP + 视口变换
  • Bresenham划线算法
  • Scanline-Zbuffer多边形填充
  • Phong光照模型 + Phong着色
  • 纹理映射(透视校正插值)

管线流程


模型读取

顶点的数据结构设定如下,其中posnormaltexcoord.obj文件中读取:

class Vertex
{
public:
Vector3 pos;
Vector3 wPos;
Vector3 normal;
TexCoord texcoord;
COLORREF color;
float rhw;
};

三角面的数据结构如下:

class Triangle
{
public:
Vertex v[3];
};

坐标变换MVP + 视口变换

本项目中采用的是Direct3D标准,即所有向量为行向量,乘法顺序为从左向右。


Model Matrix

模型读取完成后,首先对模型本身的平移(Translate)、旋转(Rotate)、缩放(Scale)三种变换进行处理。

TRS中,必须保证T要在最后相乘。
$$
Model Matrix = Scale \times Rotation \times Translate
$$

void Transform::UpdateModelMatrix()
{
modelMatrix = scale * rotation * translate;
}

Scale

Scale矩阵的设置为:
$$
\left[
\begin{matrix}
scaleX & 0 & 0 & 0 \\
0 & scaleY & 0 & 0 \\
0 & 0 & scaleZ & 0 \\
0 & 0 & 0 & 1
\end{matrix}
\right]
$$

void Transform::SetScale(float x, float y, float z)
{
Matrix44 s = Matrix44::Identity();
s.m[0][0] = x;
s.m[1][1] = y;
s.m[2][2] = z;
scale = s;
}

Rotation

欧拉旋转的顺序参照Unity标准,即ZXY。
$$
Rotation = Rz \times Rx \times Ry
$$
其中绕不同轴的Rotation矩阵的设置为:
$$
Rx =
\left[
\begin{matrix}
1 & 0 & 0 & 0\\
0 & \cos x & \sin x & 0\\
0 & -\sin x & \cos x & 0 \\
0 & 0 & 0 & 1
\end{matrix}
\right]
Ry =
\left[
\begin{matrix}
\cos y & 0 & -\sin y & 0 \\
0 & 1 & 0 & 0 \\
\sin y & 0 & \cos y & 0 \\
0 & 0 & 0 & 1
\end{matrix}
\right]
Rz =
\left[
\begin{matrix}
\cos z & \sin z & 0 & 0 \\
-\sin z & \cos z & 0 & 0 \\
0 & 0 & 1 & 0 \\
0 & 0 & 0 & 1
\end{matrix}
\right]
$$

void Transform::SetRotation(float x, float y, float z)
{
Matrix44 rx = Matrix44::Identity();
Matrix44 ry = Matrix44::Identity();
Matrix44 rz = Matrix44::Identity();

rx.m[1][1] = cosf(D2R(x));
rx.m[2][2] = cosf(D2R(x));
rx.m[2][1] = -sinf(D2R(x));
rx.m[1][2] = sinf(D2R(x));

ry.m[2][2] = cosf(D2R(y));
ry.m[0][0] = cosf(D2R(y));
ry.m[0][2] = -sinf(D2R(y));
ry.m[2][0] = sinf(D2R(y));

rz.m[0][0] = cosf(D2R(z));
rz.m[1][1] = cosf(D2R(z));
rz.m[1][0] = -sinf(D2R(z));
rz.m[0][1] = sinf(D2R(z));

rotation = rz * rx * ry;
}

其中D2R函数含义为Degree to Radian

const float PI = 3.14159265358f;
const float D2R(float deg)
{
return deg * PI / 180;
}

Rotation矩阵必然正交,原理为:
$$
\left[
\begin{matrix}
1 & 0 & 0 & 0 \\
0 & 1 & 0 & 0 \\
0 & 0 & 1 & 0 \\
0 & 0 & 0 & 1
\end{matrix}
\right]
\text {(原坐标轴)}
\times R =
R\text {(新坐标轴)}
$$
经旋转后的新坐标轴必然保持正交。


Translate

Translate矩阵的设置为:
$$
\left[
\begin{matrix}
1 & 0 & 0 & 0 \\
0 & 1 & 0 & 0 \\
0 & 0 & 1 & 0 \\
TranslateX & TranslateY & TranslateZ & 1
\end{matrix}
\right]
$$

void Transform::SetTranslate(float x, float y, float z)
{
Matrix44 t = Matrix44::Identity();
t.m[3][0] = x;
t.m[3][1] = y;
t.m[3][2] = z;
translate = t;
}

View Matrix

接下来,需要将顶点由World Space转入View Space,思路为:
$$
任一点 \xrightarrow{Follow} 相机 \xrightarrow{Translate} 原点 \xrightarrow{Rotate} 将uvw旋转至与xyz重合
$$
相机的数据结构为:

class Camera {
public:
Vector3 pos;
Vector3 lookAt;
Vector3 up;
};

求出View Space的坐标向量uvw:

Vector3 forward = (camera.lookAt - camera.pos).Normalize();
Vector3 right = (camera.up ^ forward).Normalize();
Vector3 up = (forward ^ right).Normalize();

$$
u = right \\v = up \\w = forward
$$

接下来的目标是将View Space的坐标轴uvw乘以View Matrix变换至与World Space的坐标轴xyz重合,即:
$$
u \rightarrow x \\v \rightarrow y \\w \rightarrow z
$$
而由上述思路可知:
$$
Matrix_{view} = M_T \times M_R
$$
首先易知:
$$
M_T =\left[\begin{matrix}1 & 0 & 0 & 0 \\0 & 1 & 0 & 0 \\0 & 0 & 1 & 0 \\-Camera_{posX} & -Camera_{posY} & -Camera_{posZ} & 1\end{matrix}\right]
$$
乘以$M_T$后,此时两坐标系原点已重合,然后考虑 $M_R$,首先考虑其逆过程$xyz \rightarrow uvw $:

(uvw为方向向量,不会因上一步的平移而改变)
$$
[1, 0, 0] \times R = [u_x, u_y, u_z] \\ [0, 1, 0] \times R = [v_x, v_y, v_z] \\ [0, 0, 1] \times R = [w_x, w_y, w_z]
$$
则对于$uvw \rightarrow xyz $
$$
M_R = R^{-1}
$$
旋转矩阵必正交可得:
$$
R^{-1} = R^T
$$

$$
M_R = R^T =\left[\begin{matrix}u_x & v_x & w_x & 0 \\u_y & v_y & w_y & 0 \\u_z & v_z & w_z & 0 \\0 & 0 & 0 & 1\end{matrix}\right]
$$
最终代码为:

Matrix44 t = Matrix44::Identity();
t.m[3][0] = -camera.pos.x;
t.m[3][1] = -camera.pos.y;
t.m[3][2] = -camera.pos.z;

Matrix44 r = Matrix44::Identity();
r.m[0][0] = right.x;
r.m[1][0] = right.y;
r.m[2][0] = right.z;
r.m[0][1] = up.x;
r.m[1][1] = up.y;
r.m[2][1] = up.z;
r.m[0][2] = forward.x;
r.m[1][2] = forward.y;
r.m[2][2] = forward.z;

viewMatrix = t * r;

Projection Matrix

接下来进行透视变换,透视变换的过程为:
$$
视锥体 \xrightarrow{透视矩阵} Clip Space \xrightarrow{透视除法} NDC
$$
其中,Direct3D的NDC(Normalized Device Coordinate)范围为 $[-1, 1] [-1, 1] [0, 1]$

定义屏幕宽高比为aspect,Y方向的视场角为fovy,相机到近平面与远平面的距离分别为$ N $和$ F $

现在,我们试图将视锥体内的顶点线性映射至NDC空间,可得:
$$
x’ = x \frac {1}{z\tan \frac{fovx}{2}} \\
y’ = y \frac {1}{z\tan \frac{fovy}{2}} \\
z’ = \frac{z - N}{F - N}
$$
想要将以上形式写成矩阵乘法形式非常困难,主要原因是$x’$和$y’$之中多出一个$\frac1z$,而且$z’$中的$z$无法提出为因式,于是这里我们需要使用一个十分重要的trick

由于z值的最终用途是比较顶点深度,并不需要精确映射,仅需满足在$(N, 0)$点到$(F, 1)$点之间保持单调即可。同时,我们先从$x’$和$y’$中提出公因子$\frac1z$,等到矩阵乘法之后再做处理,于是我们可以先将投影矩阵写为:
$$
\left[
\begin{matrix}
\cot(fovx/2) & 0 & 0 & 0 \\
0 & \cot(fovy/2) & 0 & 0 \\
0 & 0 & a & 1 \\
0 & 0 & b & 0
\end{matrix}
\right]
$$
其中$a$和$b$为待求未知数,而其中的1巧妙地将变换前的$z$保存在变换后的$w’$中,此时:
$$
x’ = x \cot (fovx/2) \\
y’ = y \cot (fovy/2) \\
z’ = az + b \\
w’ = z
$$
此时进行透视除法(Perspective provider),即除以$w’$,得到如下结果:
$$
x’ = \frac 1z x \cot (fovx/2) \\
y’ = \frac 1z y \cot (fovy/2) \\
z’ = \frac {az + b}{z} \\
w’ = 1
$$
其中$x’$和$y’$完全符合要求,$z’ = \frac {az + b}{z}$ 为反比例函数,在$[N, F]$区间内必然单调,符合要求。

将$(N, 0)$和$(F, 1)$点代入该式,得:
$$
\begin{cases}
\frac {aN + b}{N} = 0 \\
\frac {aF + b}{F} = 1
\end{cases}
$$
解得:
$$
\begin{cases}
a = \frac {F}{F - N} \\
b = \frac {-NF}{F - N}
\end{cases}
$$
设$Q = \frac {F}{F - N}$,则最终:
$$
\left[
\begin{matrix}
\cot(fovx/2) & 0 & 0 & 0 \\
0 & \cot(fovy/2) & 0 & 0 \\
0 & 0 & Q & 1 \\
0 & 0 & -NQ & 0
\end{matrix}
\right]
$$

void Transform::SetprojectionMatrix(float fovy, float w, float h, float zn, float zf)
{
float aspect = w / h;
float cot = 1 / tanf(fovy * 0.5f);
float Q = zf / (zf - zn);

Matrix44 p;
p.m[0][0] = cot / aspect;
p.m[1][1] = cot;
p.m[2][2] = Q;
p.m[3][2] = -Q * zn;
p.m[2][3] = 1;
projectionMatrix = p;
}

注意,这一步中最终深度信息z并非线性映射,其函数二阶导小于0,即z经映射后更接近于远平面。


Viewport Matrix

最后一步,视口变换本质上是将顶点从NDC空间映射至视口空间,即为:
$$
NDC \rightarrow Viewport \\
x: [-1, 1] \rightarrow [x_0, x_0 + width] \\
y: [-1, 1] \rightarrow [y_0, y_0 + height] \\
z: [0, 1] \rightarrow [z_{min}, z_{max}]
$$
首先进行区间缩放,缩放矩阵如下,其中Viewport坐标系Y轴竖直向下,需要与之前取反:
$$
\left[
\begin{matrix}
\frac{width}2 & 0 & 0 & 0 \\
0 & -\frac{height}2 & 0 & 0 \\
0 & 0 & z_{max} - z_{min} & 0 \\
0 & 0 & 0 & 1
\end{matrix}
\right]
$$
缩放后的区间为:
$$
x: [-1, 1] \rightarrow [-\frac{width}2, \frac{width}2] \\
y: [-1, 1] \rightarrow [-\frac{height}2, \frac{height}2] \\
z: [0, 1] \rightarrow [0, z_{max} - z_{min}]
$$
之后我们需要平移矩阵与Viewport区间进行对齐:
$$
\left[
\begin{matrix}
1 & 0 & 0 & 0 \\
0 & 1 & 0 & 0 \\
0 & 0 & 1 & 0 \\
x_0 + \frac{width}2 & y_0 + \frac{height}2 & z_{min} & 1
\end{matrix}
\right]
$$
两矩阵相乘则得到Viewport Matrix如下:
$$
\left[
\begin{matrix}
\frac{width}2 & 0 & 0 & 0 \\
0 & -\frac{height}2 & 0 & 0 \\
0 & 0 & z_{max} - z_{min} & 0 \\
x_0 + \frac{width}2 & y_0 + \frac{height}2 & z_{min} & 1
\end{matrix}
\right]
$$

void Transform::SetViewportMatrix(float x, float y, float w, float h, float zmin = 0, float zmax = 1)
{
Matrix44 vp = Matrix44::Identity();
vp.m[0][0] = w * 0.5f;
vp.m[3][0] = x + w * 0.5f;
vp.m[1][1] = -h * 0.5f;
vp.m[3][1] = y + h * 0.5f;
vp.m[2][2] = zmax - zmin;
vp.m[3][2] = zmin;
viewportMatrix = vp;
}

Bresenham划线算法

该算法主要用于模型的线框显示,主要思路是通过增量绘制而减少乘除,同时通过乘2的方式避免浮点数出现,最终提高绘制效率。

具体算法细节暂略。


Scanline-Zbuffer多边形填充

该阶段主要进行三角面内部颜色的填充。

首先根据y值对顶点进行排序为v0, v1, v2。

然后分别计算三条线段上x线性插值系数随y增加而累加的步长

float step01 = (v1.pos.y - v0.pos.y) == 0 ? 0 : 1 / (v1.pos.y - v0.pos.y);
float step02 = (v2.pos.y - v0.pos.y) == 0 ? 0 : 1 / (v2.pos.y - v0.pos.y);
float step12 = (v2.pos.y - v1.pos.y) == 0 ? 0 : 1 / (v2.pos.y - v1.pos.y);

第一段,从v0出发,对线段v0v1和v0v2之间区域进行水平扫描线填充:

int scanlineY = (int)v0.pos.y;
for (; scanlineY < v1.pos.y; scanlineY++)
{
ScanLine(scanlineY, x01, x02, Vertex::Lerp(v0, v1, k01), Vertex::Lerp(v0, v2, k02));

k01 += step01;
k02 += step02;

x01 = (int)LerpFloat(v0.pos.x, v1.pos.x, k01);
x02 = (int)LerpFloat(v0.pos.x, v2.pos.x, k02);
}

第二段,从v1处继续,对线段v1v2和v0v2之间区域进行水平扫描线填充:

for (; scanlineY <= v2.pos.y; scanlineY++)
{
ScanLine(scanlineY, x12, x02, Vertex::Lerp(v1, v2, k12), Vertex::Lerp(v0, v2, k02));

k12 += step12;
k02 += step02;

x12 = (int)LerpFloat(v1.pos.x, v2.pos.x, k12);
x02 = (int)LerpFloat(v0.pos.x, v2.pos.x, k02);
}

其中填充像素时,需要将待绘制像素的z值与zBuffer中z值比较:

void RenderBuffer::FillPixel(int x, int y, float z, COLORREF c)
{
if (y >= height || y < 0 || x >= width || x < 0)
{
return;
}
if (z >= 0 && z < zBuffer[x + y * width])
{
zBuffer[x + y * width] = z;
frameBuffer[x + y * width] = c;
}
}

Phong光照模型 + Phong着色

下面采用Phong光照模型为物体添加光照,以正确显示其在Directional Light下的颜色。

Phong着色与Gourand着色对应,指的是在片元着色器阶段而不是在顶点着色器阶段计算光照。

Directional Light的数据结构为:

class DirectionalLight
{
public:
Vector3 direction;
Vector3 color;
};

Phong光照模型中
$$
Phong = Ambient + Diffuse + Specular
$$
其中与Directional Light无关的环境光Ambient非常简单:
$$
Ambient = AmbientColor \times AmbientStrength
$$
漫反射Diffuse和镜面反射Specular则需要分别计算其因子,然后与光照颜色和片元颜色相乘:
$$
Diffuse = LightColor \times Fragment Color \times DiffuseFactor \\
Specular = LightColor \times Fragment Color \times SpecularFactor
$$
漫反射因子公式为:
$$
DiffuseFactor = L \cdot N
$$
其中,入射光方向$L$为-DirectionalLight.direction,法向量方向$N$的计算稍后讲解。

镜面反射因子公式为,其中$shininess$越大,高光部分越密集:
$$
SpecularFactor = (R \cdot V)^{shininess}
$$
其中,画图计算易得反射光方向R与视线方向V:
$$
R = 2N(L \cdot N) - L \\
V = CameraPos - FragmentWorldPos
$$
以上代码如下,注意颜色相乘时需要使用哈达玛积(Hadamard Product)使对应分量相乘:

Vector3 DirectionalLight::Apply(Vertex v, Camera camera)
{
direction = direction.Normalize();
float diffuseFactor = max(-direction * v.normal, 0);
Vector3 diffuse = Vector3::HadamardProduct(ColorToVector3(v.color), color) * diffuseFactor;

Vector3 reflect = Vector3::Reflect(-direction, v.normal).Normalize();
float specularFactor = pow(max(reflect * (camera.pos - v.wPos).Normalize(), 0), shininess);
Vector3 specular = Vector3::HadamardProduct(ColorToVector3(v.color), color) * specularFactor;

Vector3 result = diffuse + specular;
return result;
}

于是,光照计算中最重要的难点就是法向量变换,由于我们是在WorldSpace下进行光照计算,所以需要通过一个NormalMatrix将模型的法向量信息从LocalSpace变换到WorldSpace。

首先考虑切向量$T$的变换,取三角面两顶点$V1$,$V2$,则有:
$$
T = V1 - V2
$$
记ModelMatrix为$M$,则变换后的切向量:
$$
T’ = V1M - V2M = (V1 - V2)M = TM
$$
切向量的变换矩阵与顶点完全相同

而法向量与切向量必然始终垂直,记NormalMatrix为$M_N$则有:
$$
N \cdot T = 0 \\
N’ \cdot T’ = NM_N(TM)^T = NM_NM^TT^T = 0
$$
可得:
$$
M_NM^T = I
$$
即:
$$
M_N = (M^T)^{-1}
$$
由之前的顶点变换矩阵公式,且缩放矩阵$S^T = S$,旋转矩阵正交,法向量平移无效,可知:
$$
M = SRT \\
M_N = (T^TR^TS^T)^{-1} = S^{-1}R
$$
代码如下:

void Transform::UpdateNormalMatrix()
{
normalMatrix = scale.InverseDiagonal() * rotation;
}
Matrix44 Matrix44::InverseDiagonal()
{
Matrix44 mt = *this;
for (int i = 0; i < 4; i++)
{
mt.m[i][i] = mt.m[i][i] == 0 ? 0 : 1 / mt.m[i][i];
}
return mt;
}

纹理映射(透视校正插值)

之前在模型读取时,我们读取了三角形顶点所对应的纹理坐标texcoord,然而在求三角形内部纹理坐标时,如果直接在屏幕坐标系内对三角形内部的点进行重心插值(或双线性插值,结果相同),则会得到不正确的结果,这主要由之前的透视变换造成,需要进行透视校正插值(Perspective-Correct Interpolation)。

画出一条线段$A(x_0, z_0)B(x_1, z_1)$透视投影在距离相机$d$的近平面上线段$a(u_0, d)b(u_1, d)$的图,其中待插值点$C(x, z)$投影在点$c(u, d)$,易得:
$$
\frac {x_0}{z_0} = \frac {u_0}{d} \\
\frac {x_1}{z_1} = \frac {u_1}{d} \\
\frac {x}{z} = \frac {u}{d}
$$
设线段$ab$上线性插值系数为$s$,线段$AB$上线性插值系数为$t$,则有:
$$
u = u_0 + s(u_1 - u_0) \\
x = x_0 + t(x_1 - x_0) \\
z = z_0 + t(z_1 - z_0)
$$
我们已知系数$s$,需要求正确的插值系数$t$,解得:
$$
t = \frac{sz_0}{sz_0 + (1 - s)z_1}
$$
进一步得:
$$
\frac 1z = (1 - s) \frac 1{z_0} + s \frac 1{z_1}
$$
若$AB$两点分别有属性$I_0I_1$,则插值后得到$C$点属性为:
$$
\frac Iz = (1 - s) \frac {I_0}{z_0} + s \frac {I_1}{z_1}
$$
接下来问题的关键是,如何正确获取顶点在ViewSpace中的$z$值,此处不能直接使用透视除法后的顶点$z$值,因为其仅代表非线性映射后的深度值(我在这个问题上掉坑并找了三天BUG)。前文透视投影部分中提到,变换前的$z$保存在变换后的$w’$中,所以在进行透视除法之前,一定要注意保存一份RHW(Reciprocal of Homogeneous W, $\frac 1{w’}$)值备用。

此时,纹理坐标即为一个需要透视校正插值的属性$I$,于是代码如下,此处$\frac 1z$即等于RHW:

float z = 1 / LerpFloat(v0.rhw, v1.rhw, k);
r.rhw = 1 / z;

r.texcoord.x = z * LerpFloat(v0.texcoord.x * v0.rhw, v1.texcoord.x * v1.rhw, k);
r.texcoord.y = z * LerpFloat(v0.texcoord.y * v0.rhw, v1.texcoord.y * v1.rhw, k);
return r;

全文完,由纸质实习笔记整理而成。