本文开始编写渲染器的代码。
本着色器很大程度上参考了韦易笑大神的RenderHelp渲染器。我的渲染器的理论是跟着Games101和《Fundamentals of Computer Graphics, Fourth Edition》这本书来的,和韦大神渲染器中的方程不太一样(尤其是透视投影,深度测试的地方),所以如果要跟着我这系列博客学习,请务必从头看到尾,以避免公式和细节不一致的情况出现。
最后的渲染器项目在这里,gitee上也有同名镜像。
这一章我们开始编写渲染器的代码。编码前我们需要一个数学库和一个图形库。数学库用于计算矩阵,图形库用于渲染和保存结果。
我自己是手写了一份数学库,然后图形库我使用的是SDL2
。
如果你不想自己写数学库,我推荐你使用armadillo
或者Eigen
,但不推荐使用glm
,原因如下:
你可以使用glm但不使用他生成矩阵的函数,如果是这样,不如使用 armadillo
和Eigen
。他两使用SIMD进行加速,这对于软渲染这种效率低的东西来说是节省时间的好方法。
对于图形库,这就看你的心情了,你甚至可以用平台API直接开干。我使用的是封装了SDL_Surface
的Surface
类,这里给出声明,以方便熟悉其各个功能:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
class Surface final {
public:
// 加载位图,为了之后的纹理映射使用
Surface(const char *filename);
// 按照大小,生成一份空的Surface
Surface(int w, int h);
Surface(const Surface &) = delete;
~Surface();
Surface &operator=(const Surface &) = delete;
int Width() const;
int Height() const;
// 绘制点
void PutPixel(int x, int y, const Color4 &color);
// 使用指定颜色清除
void Clear(const Color4 &color);
// 保存到位图
void Save(const char *filename);
};
|
我们先搭建一个简单的渲染器,他可以清屏,在Framebuffer上画点,并且可以将Framebuffer保存成位图:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
|
/***********************************
* Renderer
***********************************/
class Renderer final {
public:
Renderer(int w, int h)
: drawColor_{0, 0, 0, 0} {
framebuffer_.reset(new Surface(w, h));
}
void SetDrawColor(const Color4 &c) { drawColor_ = c; }
void SetClearColor(const Color4 &c) { clearColor_ = c; }
std::shared_ptr<Surface> GetFramebuffer() { return framebuffer_; }
// 给Framebuffer清屏
void Clear() {
framebuffer_->Clear(clearColor_);
}
// 在Framebuffer上绘制点
void DrawPixel(int x, int y) {
if (IsPointInRect(Vec2{real(x), real(y)},
Rect{Vec2{0, 0}, framebuffer_->Size()})) {
framebuffer_->PutPixel(x, y, drawColor_);
}
}
// 设置Viewport,这里直接生成Viewport矩阵
void SetViewport(int x, int y, int w, int h) {
viewport_ = Mat44::Zeros();
viewport_.Set(0, 0, w / 2);
viewport_.Set(1, 1, -h / 2);
viewport_.Set(3, 0, w / 2 + x);
viewport_.Set(3, 1, h / 2 + y);
viewport_.Set(2, 2, 0.5);
viewport_.Set(3, 2, 1);
viewport_.Set(4, 4, 1);
}
// 保存Framebuffer到位图
void Save(const char *filename) { framebuffer_->Save(filename); }
// 绘制三角形图元,这是最重要的函数
bool DrawPrimitive();
private:
std::shared_ptr<Surface> framebuffer_;
Color4 drawColor_;
Color4 clearColor_;
Mat44 viewport_;
};
|
完成之后你应该可以在Framebuffer上画点,清屏了,并且保存到位图了。
让我们来做个测试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
|
int main() {
Renderer renderer(480, 360);
// 设置绘制颜色
renderer.SetDrawColor(Color4{1, 0, 0, 1});
// 设置清屏颜色
renderer.SetClearColor(Color4{0.2, 0.2, 0.2, 1});
// 清屏
renderer.Clear();
// 画一条水平的直线
for (int i = 100; i < 400; i++) {
renderer.DrawPixel(i, 180);
}
// 保存到位图
renderer.Save("test_renderer.bmp");
return 0;
}
|
你应该可以看到这个结果:
我们希望做一个可编程渲染管线的着色器,所以这里来做着色器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
|
/***********************************
* Shader
***********************************/
struct ShaderContext {
std::unordered_map<int, real> varyingFloat;
std::unordered_map<int, Vec2> varyingVec2;
std::unordered_map<int, Vec3> varyingVec3;
std::unordered_map<int, Vec4> varyingVec4;
void Clear() {
varyingFloat.clear();
varyingVec2.clear();
varyingVec3.clear();
varyingVec4.clear();
}
};
using VertexShader = std::function<Vec4(int index, ShaderContext &output)>;
using FragmentShader = std::function<Vec4(ShaderContext &input)>;
|
ShaderContext
模拟OpenGL中的layout
变量,而顶点着色器和片段着色器仅仅是函数对象。
接下来让渲染器可以设置着色器:
1
2
3
4
5
6
7
8
9
10
|
class Renderer {
public:
void SetVertexShader(VertexShader shader) { vertexShader_ = shader; }
void SetFragmentShader(FragmentShader shader) { fragmentShader_ = shader; }
// ...
private:
VertexShader vertexShader_ = nullptr;
FragmentShader fragmentShader_ = nullptr;
// ...
};
|
接下来我们要编写最复杂的光栅化部分,首先我们定义渲染器中一个顶点所需要的各项信息:
1
2
3
4
5
6
7
8
9
|
// in class Renderer
private:
struct Vertex {
ShaderContext context;
real rhw;
Vec4 pos;
Vec3 spf;
Vec2 spi;
} vertices_[3];
|
context
:是着色器上下文,里面存放着所有Uniform变量
rhw
:是进行MVP变换后的w坐标的倒数
pos
:是顶点在全局空间中的坐标
spf
:是顶点经过MVP变换后的坐标
spi
:是顶点经过MVP变换后的x, y坐标的整数值
然后我们开始编写光栅化部分,根据我们第0章说的渲染管线,首先我们需要对于所有输入的点运行顶点着色器:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
|
// in class Renderer
bool DrawPrimitive() {
if (!vertexShader_) {
return false;
}
// 对于每个顶点,我们要运行顶点着色器,进行裁剪和面剔除,进视口变换
for (int i = 0; i < 3; i++) {
Vertex& vertex = vertices_[i];
// 1. 清空着色器上下文
vertex.context.Clear();
// 2. 运行顶点着色器
vertex.pos = vertexShader_(i, vertices_[i].context);
// 得到w的倒数,注意预防除0错误
vertex.rhw = 1.0 / (vertex.pos.w == 0 ? 1e-5 : vertex.pos.w);
// 接下来是面剔除和裁切
}
|
在运行顶点着色器之前,我们需要将ShaderContext
里的Uniform变量全部清除。
然后第二步,运行顶点着色器,并且记录下rhw
的值。
顶点着色器需要一个着色器上下文,它会将所有的Uniform变量存进去,然后返回此顶点经过处理后的值(相当于gl_Position
)。
这里可能会有疑问:渲染管线的第一步明明是顶点输入,你这顶点怎么输入呢?
如果要完全仿照OpenGL,你可以定义自己的Vertex Buffer,Vertex Attribute Pointer和Element Index Buffer。但是我们这里从简,直接在顶点着色器里处理:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
|
// 这是渲染器完成后的使用方法,展示了如何进行顶点输入:
// 定义一个Uniform变量的序号
enum UniformVar {
Uniform_Color = 0,
};
int main() {
Renderer renderer(480, 320);
renderer.SetClearColor(Color4{0.1, 0.1, 0.1, 1});
renderer.Clear();
renderer.SetViewport(0, 0, 480, 320);
// 定义自己的顶点数据
struct { Vec4 pos; Vec4 color; } vs_input[3] = {
//坐标 颜色
{Vec4{-0.5, -0.5, 1, 1}, Vec4{1, 0, 0, 1}},
{Vec4{0.5, -0.5, 1, 1}, Vec4{0, 1, 0, 1}},
{Vec4{0, 0.5, 1, 1}, Vec4{0, 0, 1, 1}},
};
// 顶点着色器是Lambda,直接捕获顶点数据完成顶点输入,然后处理顶点数据
renderer.SetVertexShader([&](int index, ShaderContext& output) {
// 将颜色数据放入ShaderContext,
output.varyingVec4[Uniform_Color] = vs_input[index].color;
// 返回处理后的顶点
return vs_input[index].pos;
});
renderer.SetFragmentShader([&](ShaderContext& input) {
// 片段着色器从ShaderContext中取出Uniform变量
return input.varyingVec4[Uniform_Color];
});
renderer.DrawPrimitive();
renderer.Save("hello_triangle.bmp");
return 0;
}
|
顶点着色器运行完之后应该运行细分着色器和片段着色器,但是我们这里为了方便就不写那两个着色器了。
那么接下来就到了面剔除和裁剪的部分,面剔除使用向量叉积即可解决:
1
2
3
4
5
6
7
8
9
|
// 6. face culling, cull the CCW face
real result = Cross(Vec<2>(vertices_[1].pos - vertices_[0].pos),
Vec<2>(vertices_[2].pos - vertices_[1].pos));
if (faceCull_ == CCW && result >= 0) {
return false;
} else if (faceCull_ == CW && result <= 0) {
return false;
}
|
CCW
代表要剔除逆时针面,CW
要剔除顺时针面。这里使用向量叉积即可判断顺,逆时针。
裁剪可以做的很复杂,比如使用Cohen-Sutherland algorithm算法进行裁剪。我们这里图简单使用简单裁剪,即只要三角形任意一个边超出屏幕,我们就直接丢弃整个三角形。
由于经过投影矩阵后的点理论上在$[-1, 1]^3$中,而如果是透视投影,由于将$(x, y, z, 1)$转换为$(zx, zy, z^2, z)$,所以其被转换到$[-|z|, |z|]^3$上。我们只要判断点是否在这个区间外就可以了:
1
2
3
4
5
6
7
8
|
// 3. clipping, if AABB not intersect with screen, clip it
for (int i = 0; i < 3; i++) {
real absw = std::abs(vertices_[i].pos.w);
if (vertices_[i].pos.x < -absw || vertices_[i].pos.x > absw ||
vertices_[i].pos.y < -absw || vertices_[i].pos.y > absw) {
return false;
}
}
|
面剔除和裁剪后要进行透视除法和变换,代码也很简单:
1
2
3
4
5
6
7
8
9
10
|
for (auto& vertex : vertices_) {
// 4. perspective divide
vertex.pos *= vertex.rhw;
// 5. viewport transform and prepare to step into rasterization
vertex.spf = Vec<3>(viewport_ * vertex.pos);
vertex.spi.x = int(vertex.spf.x + 0.5f);
vertex.spi.y = int(vertex.spf.y + 0.5f);
}
|
这里vertex.spi
是点在$XoY$平面上的坐标,这里加0.5是因为,根据定义,我们将像素看场一个小正方形,需要用其中心的坐标来计算。
经过了顶点着色器,裁剪,面剔除和视口变换后,终于来到了最终要的光栅化阶段。
光栅化就是对屏幕上的所有点,判断点是否在三角形内:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
Rect boundingRect = GetTriangleAABB(vertices_[0].spi, vertices_[1].spi, vertices_[2].spi);
int minX = std::max<int>(boundingRect.pos.x, 0),
minY = std::max<int>(boundingRect.pos.y, 0),
maxX = std::min<int>(boundingRect.pos.x + boundingRect.size.w, framebuffer_->Width()),
maxY = std::min<int>(boundingRect.pos.y + boundingRect.size.h, framebuffer_->Height());
// 7. rasterization
for (int i = minX; i < maxX; i++) {
for (int j = minY; j < maxY; j++) {
Vec2 p{i + 0.5f, j+ 0.5f}; // [1]
if (!IsPointInRect(p, boundingRect)) { //[2]
continue;
}
// 7.1 barycentric calculate
// [5]
Vec3 barycentric = Barycentric(vertices_[0].spi,
vertices_[1].spi,
vertices_[2].spi,
p);
real rhw = vertices_[0].rhw * barycentric.alpha + vertices_[1].rhw * barycentric.beta + vertices_[2].rhw * barycentric.gamma;
float w = 1.0f / ((rhw != 0.0f)? rhw : 1.0f);
barycentric.alpha *= vertices_[0].rhw * w;
barycentric.beta *= vertices_[1].rhw * w;
barycentric.gamma *= vertices_[2].rhw * w;
if (barycentric.alpha < 0 && barycentric.beta < 0 && barycentric.gamma < 0) {
return false;
}
if (barycentric.alpha < 0 || barycentric.beta < 0 || barycentric.gamma < 0) {
continue;
}
real z = 1.0 / rhw;
// 7.2 update depth buffer(camera look at -z, but depth buffer store positive value, so we take the opposite of 1.0 / rhw)
if (z <= depthBuffer_->Get(i, j)) { // [6]
continue;
}
depthBuffer_->Set(i, j, z);
// 下面要运行片段着色器了
|
首先得到三角形的AABB包围盒boundingRect
,用来得到三角形AABB和屏幕相交的矩形,来减少遍历的点。然后一个双重for
循环遍历屏幕上的点,注意这里的[1]
语句同样将点偏移到像素的中心。
然后[2]
使用boundingBox
快速判断点是否在三角形的AABB内,如果不在直接丢弃。
然后是重心坐标的计算,这个在第二节中说了,有两种方法,我这里使用的是第二种方法:
1
2
3
4
5
6
7
8
9
10
11
12
13
|
inline Vec3 Barycentric(const Vec2& v1, const Vec2& v2, const Vec2& v3, const Vec2& p) {
Vec3 result;
Vec3 c1{v1.x - v2.x, v1.x - v3.x, p.x - v1.x},
c2{v1.y - v2.y, v1.y - v3.y, p.y - v1.y};
result = Cross(c1, c2);
if (result.z == 0) {
// (-1, -1, -1) means a invalid condition, should discard this point
return Vec3{-1, -1, -1};
}
return Vec3{1 - result.x / result.z - result.y / result.z,
result.x / result.z,
result.y / result.z};
}
|
如果返回$(-1, -1, -1)$,则表示计算失败,三角形退化成直线了,那么我们直接丢弃整个三角形(即[3]
处)。
如果这个点不在三角形上,那重心坐标必定有一个值为负数,我们就丢弃这个点(即[4]
处)。
步骤[5]
则是通过重心坐标插值出z坐标。这里有一个非常要注意的点,和你如何设计深度缓冲有关:
- 如果你的深度缓冲是只存储各个片段的深度值,不像OpenGL那样要求他们在$[0, 1]$之间的话,你应该使用三角形的全局顶点去插值。
- 如果你想让深度缓冲存储的范围在$[0, 1]$中,那么你就必须使用视口变换后的点进行深度插值(也就是我这里做的),因为视口变换会直接将z坐标变换到$[0, 1]$中。
接下来的[6]
则说明了如何更新深度缓存。这里你可能会有疑惑:网上的教程都是深度小的绘制,深度大的丢弃,你这里怎么反过来了?
回忆上一章说的透视投影矩阵的作用,其是将$(x, y, z, w)$变换到$(zx, zy, z^2, zw)$,然后经过透视除法,令每个坐标在$[-1, 1]^3$中,然后经过视口变换,得到:
$$
\begin{align}
x &\in [-\frac{w}{2}, \frac{w}{2}] \
y &\in [-\frac{h}{2}, \frac{h}{2}] \
z &\in [0, 1] \
\end{align}
$$
那么也就是说,z坐标从:$[-1, 1]$变换到了$[0, 1]$,即越靠近近平面变换后的z越靠近1,而越远离近平面的z越靠近0。也就是说,z值大的点应该在z值小的点的前面。
OpenGL中之所以是z值小的在前面,是因为他的透视投影矩阵和我们的不太一样,从而导致这里的深度测试也不一样。
所以如果你输出深度图的话,将会是离得近的点越白,远的点反而黑,这和OpenGL输出的深度图正好相反。
然后还有一点要注意:常理来说,深度测试应该在片段着色器之后,我们这里是提前深度测试,这是在保证片段着色器不改变顶点z坐标的情况下才能做的优化(我们全程都不会改变z值,所以进行了优化)。如果你的片段着色器需要改变z值,请将深度测试放到片段着色器之后。
然后就是对ShaderContext
中的所有值进行插值,然后运行片段着色器了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
|
// 7.3 interpolation other varying properties
ShaderContext input;
ShaderContext& i0 = vertices_[0].context,
i1 = vertices_[1].context,
i2 = vertices_[2].context;
for (auto& [key, value] : i0.varyingFloat) {
input.varyingFloat[key] = i0.varyingFloat[key] * barycentric.alpha +
i1.varyingFloat[key] * barycentric.beta +
i2.varyingFloat[key] * barycentric.gamma;
}
for (auto& [key, value] : i0.varyingVec2) {
input.varyingVec2[key] = i0.varyingVec2[key] * barycentric.alpha +
i1.varyingVec2[key] * barycentric.beta +
i2.varyingVec2[key] * barycentric.gamma;
}
// ... 其他的成员如法炮制,这里直接省略
// 8. run Fragment Shader
Vec4 color{0, 0, 0, 0};
if (fragmentShader_) {
// 运行片段着色器
color = fragmentShader_(input);
// 绘制点到Framebuffer
framebuffer_->PutPixel(i, j, color);
}
}
}
return true;
}
|
这里我们还有模板测试,Alpha测试和融混没有做。如果有时间,我会在以后的章节中补上。
让我们尝试在透视投影下渲染一个三角形:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
#include "renderer.hpp"
enum UniformVar {
Color = 0,
};
int main() {
Renderer renderer(480, 320);
renderer.SetClearColor(Color4{0.1, 0.1, 0.1, 1});
renderer.Clear();
// 设置Viewport
renderer.SetViewport(0, 0, 480, 320);
// 顶点属性
struct { Vec4 pos; Vec4 color; } vs_input[3] = {
{Vec4{0.5, 0.5, -1, 1}, Vec4{1, 0, 0, 1}},
{Vec4{0.5, -0.5, -1, 1}, Vec4{0, 1, 0, 1}},
{Vec4{-0.5, -0.5, -1, 1}, Vec4{0, 0, 1, 1}},
};
// 生成透视投影矩阵,注意这里的near和far参数是近,远平面的坐标,而不是到原点的距离
auto perspMat = CreatePersp(M_PI * 0.5, 480.f/320.f, -0.1, -100);
// 设置着色器
renderer.SetVertexShader([&](int index, ShaderContext& output) {
output.varyingVec4[Color] = vs_input[index].color;
return perspMat * vs_input[index].pos;
});
renderer.SetFragmentShader([&](ShaderContext& input) {
return input.varyingVec4[Color];
});
// 绘制三角形
renderer.DrawPrimitive();
// 保存结果到位图
renderer.Save("persp_triangle.bmp");
return 0;
}
|
运行成功后应该会生成如下的图片:
你可能会发现图片是上下颠倒的。这是因为我们默认的坐标系是y轴向上,x轴向右,z轴朝向屏幕外面。而大多数窗体程序的坐标都是y轴向下的,这回导致图片上下颠倒。
如果想要修复这种颠倒,可以在viewport
矩阵中翻转y坐标。