从0开始制作软渲染器(三)

本文开始编写渲染器的代码。

本着色器很大程度上参考了韦易笑大神的RenderHelp渲染器。我的渲染器的理论是跟着Games101和《Fundamentals of Computer Graphics, Fourth Edition》这本书来的,和韦大神渲染器中的方程不太一样(尤其是透视投影,深度测试的地方),所以如果要跟着我这系列博客学习,请务必从头看到尾,以避免公式和细节不一致的情况出现。

最后的渲染器项目在这里,gitee上也有同名镜像。

开始编码前的准备工作

这一章我们开始编写渲染器的代码。编码前我们需要一个数学库和一个图形库。数学库用于计算矩阵,图形库用于渲染和保存结果。

我自己是手写了一份数学库,然后图形库我使用的是SDL2

如果你不想自己写数学库,我推荐你使用armadillo或者Eigen,但不推荐使用glm,原因如下:

  • 我们会手动输入矩阵,这可以检验我们推导的矩阵的正确性

  • 也不能使用glm已有的矩阵,glm的某些矩阵和我们的不一样(比如投影矩阵对OpenGL特化,或者推导的方式不一样从而达到了形式不一样的矩阵)

你可以使用glm但不使用他生成矩阵的函数,如果是这样,不如使用 armadilloEigen。他两使用SIMD进行加速,这对于软渲染这种效率低的东西来说是节省时间的好方法。

对于图形库,这就看你的心情了,你甚至可以用平台API直接开干。我使用的是封装了SDL_SurfaceSurface类,这里给出声明,以方便熟悉其各个功能:

 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;
}

你应该可以看到这个结果:

test_renderer.bmp

着色器的编写

我们希望做一个可编程渲染管线的着色器,所以这里来做着色器:

 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;
}

运行成功后应该会生成如下的图片:

persp_triangle.bmp

你可能会发现图片是上下颠倒的。这是因为我们默认的坐标系是y轴向上,x轴向右,z轴朝向屏幕外面。而大多数窗体程序的坐标都是y轴向下的,这回导致图片上下颠倒。

如果想要修复这种颠倒,可以在viewport矩阵中翻转y坐标。

updatedupdated2023-06-082023-06-08