软光栅渲染器开发01-背景介绍以及第一个三角形

1. 为什么是软光栅渲染器

正常来讲,一个有志于进行游戏或者图形开发的人,在实际的生产环境中,大多是依赖于游戏引擎或者常见图形API(OpenGL, DirectX, Vulkan)的封装来进行工作。不会有人放着GPU的硬件渲染管线不去使用,转而在CPU端去自己模拟一套低效的实现。所以在深入讨论实现软光栅渲染器之前,我可能需要(编造)一个理由来说服你继续看下去。

What I can not create, I do not understand. -Richard Feynman

上面这句话是由伟大的物理学家费曼所言,如果你无法创造出来某样东西,你自然也没有真正的理解它。所以我给你的第一个理由便是,通过实现一个简单的渲染管线,你可以对图形渲染的原理有更加深刻的理解。至于第二个理由便是,大部分人如果一上来直接去学图形API的使用的话,往往会学的一头雾水(比如我自己),因为你并不是在实践计算机图形学,而是沦为了一个简单的API caller。并且除了这两点之外,没事自己造造轮子放在Github上也有助于自己代码能力的提升,毕竟计算机专业还是以多动手为主的嘛。

所以基于这些考虑,我准备结合代码从零讲一讲我的软光栅渲染器是怎么实现的。你可以在Github链接中找到我的所有代码。

2. 图形渲染管线的整体架构

在开始具体的探讨代码之前,还有一个基础知识需要科普一下,也就是渲染管线的整体结构。

上图是LearnOpenGL上的一张关于图形渲染管线的整体流程图。让我们按照顺序来分析一下其中各个环节都在做些什么。

  1. 顶点数据:组成模型的所有顶点的坐标。
  2. 顶点着色器: Shader指的是运行在GPU上的一小段程序,顶点着色器主要是负责对我们的顶点进行几何变换,使其能从不同的坐标空间中进行转换(这里你可能不太了解坐标空间的概念,在后面的章节我会详细解释。)
  3. 图元装配:将顶点按照其组成三角形的顺序重新组装。
  4. 几何着色器: 做一些模型细分或者简化的工作,同样是Shader程序。
  5. 光栅化:在有了三角形各个顶点在屏幕的位置的情况下,我们将其画在屏幕上。
  6. 片段着色器:对屏幕中的每个像素都会执行一遍的Shader程序,一般用来计算最后的像素颜色。
  7. 测试与混合: 做类似于深度测试,模板测试,以及颜色混合的操作。

可以看到其实图形渲染管线非常简单,就是在给定光源,几何体,摄像机等相关参数的情况下,我们如何将3维空间的物体绘制在二维屏幕上,或者你可以理解为用数学方法来不断的模拟相机照相,只不过这里的相机需要一秒钟照非常多的照片就是了。

3. 还是先画三角形吧

在看完上面的一连串流程后,你可能觉得要学的东西实在是太多了,这里面的每个流程怎么都是我的知识盲区。但是先不要去关注那些复杂的东西,让我们从一个最简单的事情开始做起,也就是画三角形。只考虑渲染管线的光栅化阶段,即在给定三角形三个顶点的屏幕坐标下情况下,我们应该如何从屏幕中画出三角形。

常规来讲,三角形的光栅化有两类算法,一类是基于扫描线的算法,另一种则是基于包围盒的算法,其中前者是比较适合常规CPU进行模拟,而后者因为易于并行化,所以是现在GPU的标准实现方式。因为我们的目标是要模拟硬件渲染管线,所以这里主要讲述后者,也就是基于包围盒的算法。

当我们给定三角形的三个坐标时,我们可以很简单的得到如下的一个包围盒。

可以看到这种包围盒是平平整整,上下和左右分别与X轴与Y轴对齐,所以这种包围盒也叫做AABB包围盒(Axis-aligned bounding box), 这种包围盒虽然有缺点,但是求解起来十分简单,只要找到三角形顶点的x,y坐标的最小值和最大值就可以迅速得到。在有了这个包围盒之后,我们便可以通过相关算法来判断包围盒内的一个点是不是在三角形内部,如果判断出来是的话,我们便将目标像素值填入其中。

在这里我是使用基于重心坐标的方法来判断一个点是不是在三角形内,而关于重心坐标的相关知识,我则推荐知乎的一个回答来进行学习,选择基于重心坐标的做法的另一个重要原因则是,当我们有了三角形内部其中一个点的重心坐标后,我们便可以很轻松的进行插值计算该点的顶点属性,例如颜色,法线,坐标等等。

for (int x = min_x; x <= max_x; ++x) {
    for (int y = min_y; y <= max_y; ++y) {
      // 用重心坐标来判断当前点是不是在三角形内,
      // https://zhuanlan.zhihu.com/p/65495373参考证明资料
      Vec3f x_part(sreen_coord_b.x - sreen_coord_a.x,
                   sreen_coord_c.x - sreen_coord_a.x, sreen_coord_a.x - x);
      Vec3f y_part(sreen_coord_b.y - sreen_coord_a.y,
                   sreen_coord_c.y - sreen_coord_a.y, sreen_coord_a.y - y);
      Vec3f u_part = VectorCross(x_part, y_part);
      Vec3f barycentric(1.0f - (u_part.x + u_part.y) / u_part.z,
                        u_part.x / u_part.z, u_part.y / u_part.z);
      if (barycentric.x >= 0 && barycentric.y >= 0 && barycentric.z >= 0) {
        // 通过正确的深度来计算插值,
        // 因为屏幕空间下直接利用重心坐标系的结果是不符合在观察坐标系的所看到的深度
        // 其根本原因是,对于深度来说,透视投影的变化对于深度(z)来说并非线性变化,所以投影过后的z值也不能简单的根据深度来进行插值
        float z = 1 / (barycentric.x * (1 / sreen_coord_a.z) +
                       barycentric.y * (1 / sreen_coord_b.z) +
                       barycentric.z * (1 / sreen_coord_c.z));
        // 进行深度测试, early-z, 未通过深度测试的片元则被丢弃
        if (y >= (int)m_depth_buffer.size() ||
            x >= (int)m_depth_buffer[0].size() || m_depth_buffer[y][x] < z)
          continue;
        m_depth_buffer[y][x] = z;
        ShaderVaryingData fragment;
        utils::BarycentricInterplate(fragment, varying_data_array, barycentric);
        Vec4f color = m_shader->RunFragmentShader(fragment);
        SetColor(color);
        SDL_RenderDrawPoint(m_renderer, x, y);
      }
    }
  }
}

上面这段代码便是对应在我的渲染器中所使用的实际代码,基本逻辑和我文中所提及的一样,不过里面可能有些额外的操作,例如这里的深度测试以及透视矫正等,这些我都会在之后的博客中进行讲解。这里的关键是保证你理解了三角形的光栅化以及利用重心坐标来判断一个点是不是在三角形内。当你理解后,你就可以很简单的画出我下面的这个三角形。

总的来说,这就是这篇博客的全部内容了,我们介绍了下项目背景,然后画了个三角形,如果我鸽的不严重的话,我应该会很快的介绍下如何渲染管线中光栅化前的步骤以及代码,也就是如何在3维坐标系中进行各种变化。
posted @ 2022-10-15 20:56  相隔半世  阅读(74)  评论(0编辑  收藏  举报