渲染管线

这篇文章的实验内容,我觉得很有意思,我们将模拟 OpenGL 的相关接口。

在文章 《空间变换》 中,我们把坐标数据、矩阵运算等内容一股脑塞在一个渲染任务类里面。这肯定是不好的,我们需要重构一下,便于管理和扩展。

我们也不用再想着法子,思考如何进行重构。因为 OpenGL 这套接口已经是抽象过后的产物了,所以我们直接拿过来用。这篇文章以 OpenGL 接口为参考,分为四个部分。

第一部分,我们引入 OpenGL 中 VBO/VAO 这套数据管理机制。

第二部分,我们进行着色器概念的抽象,实现一个着色器类。

第三部分,我们实现 OpenGL 的绘制接口模拟,在其中实现渲染管线的流程。

第四部分,记录一下显示不符合预期问题的排查过程,加深对空间变换原理的理解。

因为核心是重构问题,所以在本篇文章中,代码会贴的很多。

1. VBO/VAO

1.1 glGenBuffers

我们先回顾一下 glGenBuffers 接口。参数 n 指定要生成的缓冲区对象数量;参数 buffers,是一个数组,返回生成的缓冲区对象的名称。

  • void glGenBuffers(GLsizei n, GLuint* buffers);

所以,当务之急,我们要先定义 buffer object。代码清单 1.1.1 是我们实现的 buffer object 类,其中记录了 buffer 的名称、大小和分配的地址。

代码清单 1.1.1 BufferObject
  1. class TBufferObject
  2. {
  3. public:
  4.     TBufferObject(uint32_t id);
  5.     ~TBufferObject();
  6.  
  7.     void SetBufferData(uint32_t size, void* data);
  8.     uint8_t* GetBufferData();
  9.  
  10.     /* Debug */
  11.     void Print() const;
  12.  
  13. private:
  14.     uint32_t m_id;
  15.     uint32_t m_size;
  16.     uint8_t* m_pBuffer;
  17. };

如代码清单 1.1.2 所示,BufferObject 初始化时不分配大小,而是使用 SetBufferData 接口分配 buffer 的大小以及具体的内容。

代码清单 1.1.2 BufferObject 实现
  1. TBufferObject::TBufferObject(uint32_t id)
  2.     : m_id(id),
  3.       m_size(0),
  4.       m_pBuffer(NULL)
  5. {
  6. }
  7.  
  8. TBufferObject::~TBufferObject()
  9. {
  10.     delete[] m_pBuffer;
  11. }
  12.  
  13. void TBufferObject::SetBufferData(uint32_t size, void* data)
  14. {
  15.     if (size > m_size)
  16.     {
  17.         delete[] m_pBuffer;
  18.         m_pBuffer = new uint8_t[size];
  19.     }
  20.  
  21.     m_size = size;
  22.  
  23.     if (data != NULL && size > 0)
  24.     {
  25.         memcpy(m_pBuffer, data, size);
  26.     }
  27. }

buffer object 实现完成后,我们就可以着手实现 glGenBuffers 接口了。如代码清单 1.1.3 所示,我们实现了 GenBuffers 接口。buffer 名称依次递增,但是释放了的名称,还能“回收利用”。内部使用 map 表,记录 buffer 名称和实际 buffer object 的映射。

代码清单 1.1.3 GenBuffers
  1. class TSoftRenderer
  2. {
  3.     /* VBO */
  4.     std::unordered_map<uint32_t, TBufferObject*> m_bufferMap;
  5.     std::queue<uint32_t> m_freeBufferIds;
  6.     uint32_t m_nextBufferId;
  7. };
  1. uint32_t TSoftRenderer::AllocateBufferId()
  2. {
  3.     if (m_freeBufferIds.empty())
  4.     {
  5.         return ++m_nextBufferId;
  6.     }
  7.     else
  8.     {
  9.         uint32_t id = m_freeBufferIds.front();
  10.         m_freeBufferIds.pop();
  11.         return id;
  12.     }
  13. }
  14.  
  15. void TSoftRenderer::GenBuffers(uint32_t n, uint32_t* buffers)
  16. {
  17.     for (uint32_t i = 0; i < n; i++)
  18.     {
  19.         uint32_t id = AllocateBufferId();
  20.         m_bufferMap[id] = new TBufferObject(id);
  21.         buffers[i] = id;
  22.     }
  23. }

接口对应的 delete 接口实现都很简单,文章中都不赘述。

1.2 glBindBuffer

回顾 glBindBuffer 接口。参数 target 指定缓冲区对象绑定的目标;参数 buffer 指定要绑定的缓冲区名称。

  • void glBindBuffer(GLenum target, GLuint buffer);

绑定目标的话,目前我们只使用到 GL_ARRAY_BUFFERGL_ELEMENT_ARRAY_BUFFER。它们分别表示顶点属性的缓冲区和顶点索引的缓冲区。所以如代码清单 1.2.1 所示,我们对这两个目标进行定义。

代码清单 1.2.1 target
  1. enum class TBufferType
  2. {
  3.     ArrayBuffer,
  4.     ElementArrayBuffer
  5. };

BindBuffer 的逻辑不复杂,如代码清单 1.2.2 所示,就是状态的记录。在内部,我们记录绑定的 VBO 和 EBO。

代码清单 1.2.2 BindBuffer
  1. class TSoftRenderer
  2. {
  3.     /* Bounded */
  4.     TBufferObject*      m_currentArrayBuffer;
  5.     TBufferObject*      m_currentElementBuffer;
  6. };
  1. void TSoftRenderer::BindBuffer(TBufferType target, uint32_t buffer)
  2. {
  3.     TBufferObject* bufferPtr = NULL;
  4.     if (buffer != 0)
  5.     {
  6.         auto it = m_bufferMap.find(buffer);
  7.         assert(it != m_bufferMap.end());
  8.  
  9.         bufferPtr = it->second;
  10.     }
  11.  
  12.     switch (target)
  13.     {
  14.     case TBufferType::ArrayBuffer:
  15.         m_currentArrayBuffer = bufferPtr;
  16.         break;
  17.     case TBufferType::ElementArrayBuffer:
  18.         m_currentElementBuffer = bufferPtr;
  19.         break;
  20.     default:
  21.         assert(0);
  22.         break;
  23.     }
  24. }

缓冲区名称传递为 0,解除当前绑定。此处我们将其指向 NULL。

1.3 glBufferData

回顾 glBufferData 接口。参数 target 指定要更新的缓冲区目标;参数 size 指定数据的存储大小;参数 data 指定要复制数据的指针;参数 usage 指定缓冲区的使用模式。

  • void glBufferData(GLenum target, GLsizeiptr size, const void* data, GLenum usage);

实现好 GenBuffers 和 BindBuffer 之后,我们就可以通过 BufferData 进行 buffer 内容的设置。实现见代码清单 1.3,就是内部调用了之前实现的 BufferObject 的 SetBufferData 接口。

代码清单 1.3 BindBuffer
  1. void TSoftRenderer::BufferData(TBufferType target, uint32_t size, void* data)
  2. {
  3.     TBufferObject* buffer = NULL;
  4.  
  5.     switch (target)
  6.     {
  7.     case TBufferType::ArrayBuffer:
  8.         buffer = m_currentArrayBuffer;
  9.         break;
  10.     case TBufferType::ElementArrayBuffer:
  11.         buffer = m_currentElementBuffer;
  12.         break;
  13.     default:
  14.         break;
  15.     }
  16.  
  17.     assert(buffer != NULL);
  18.     buffer->SetBufferData(size, data);
  19. }

1.4 glGenVertexArrays

buffer object 相关的接口已经都实现完毕,现在我们需要实现 vertex array object。VAO 存储多个顶点的相关状态,包括顶点缓冲区对象和顶点属性配置。即 VAO 不仅存储需要使用的 buffer object,还记录你如何使用这些 buffer object。

VAO 通过 glGenVertexArrays 接口创建。不过在此之前,我们需要先定义 VAO。

  • void glGenVertexArrays(GLsizei n, GLuint* arrays);

代码清单 1.4.1 是我们对 VAO 的定义。它以 index 为“主键”查询记录的 VBO,同时还记录 VBO 的顶点属性信息。

代码清单 1.4.1 VAO
  1. enum class TAttributeType
  2. {
  3.     Float
  4. };
  5.  
  6. struct TVertexAttribute
  7. {
  8.     TAttributeType type;
  9.     uint32_t count;
  10.     uint32_t stride;
  11.     uint32_t offset;
  12.  
  13.     TVertexAttribute();
  14.     TVertexAttribute(TAttributeType type, uint32_t count, uint32_t stride, uint32_t offset);
  15. };
  16.  
  17. struct TVertexAttribBinding
  18. {
  19.     TBufferObject* buffer;
  20.     TVertexAttribute attribute;
  21.  
  22.     TVertexAttribBinding();
  23.     TVertexAttribBinding(TBufferObject* buffer, TVertexAttribute attribute);
  24. };
  25.  
  26. class TVertexArrayObject
  27. {
  28. public:
  29.     TVertexArrayObject(uint32_t id);
  30.  
  31.     void AddVertexAttribBinding(uint32_t index, TBufferObject* buffer, const TVertexAttribute& attribute);
  32.     const TVertexAttribBinding* GetVertexAttribBinding(uint32_t index) const;
  33.  
  34.     /* Debug */
  35.     void Print() const;
  36. private:
  37.     uint32_t m_id;
  38.     std::unordered_map<uint32_t, TVertexAttribBinding> m_bindings;
  39. };

GenVertexArrays 的实现和 GenBuffers 的逻辑是一模一样的。贴出代码清单 1.4.2 中定义的相关变量,就能“脑补”出实现,这边不再赘述。

代码清单 1.4.2 GenVertexArrays
  1. class TSoftRenderer
  2. {
  3.     /* VAO */
  4.     std::unordered_map<uint32_t, TVertexArrayObject*> m_vaoMap;
  5.     std::queue<uint32_t> m_freeVaoIds;
  6.     uint32_t m_nextVaoId;
  7.  
  8.     uint32_t AllocateVaoId();
  9. };

1.5 glBindVertexArray

回顾 glBindVertexArray 接口。参数 array 指定要绑定的顶点数组对象的名称。

  • void glBindVertexArray(GLuint array);

BindVertexArray 的实现方式和 BindBuffer 一样。如代码清单 1.5 所示,我们内部记录绑定的 VAO。

代码清单 1.5 GenVertexArrays
  1. class TSoftRenderer
  2. {
  3.     /* Bounded */
  4.     TVertexArrayObject* m_currentVertexArray;
  5. };

1.6 glVertexAttribPointer

回顾 glVertexAttribPointer 接口。参数 index 指定要修改的顶点属性索引;参数 size 指定每个顶点属性的组件数量;参数 type 指定数据类型;参数 normalized 指定数据是否要被标准化;参数 stride 指定属性组之间的偏移;参数 pointer 指定组件内的偏移量。

  • void glVertexAttribPointer(
  •     GLuint index,
  •     GLint size,
  •     GLenum type,
  •     GLboolean normalized,
  •     GLsizei stride,
  •     const void* pointer
  • );

这么多参数,其实就是我们之前定义的顶点属性 VertexAttribute。GenVertexArrays 的实现也不复杂,如代码清单 1.6 所示,我们调用 AddVertexAttribBinding 将当前绑定的 VBO,以及 index 和顶点属性进行映射。

代码清单 1.6 GenVertexArrays
  1. void TSoftRenderer::VertexAttribPointer(
  2.     uint32_t index,
  3.     uint32_t count,
  4. #if 0
  5.     TAttributeType type,
  6. #endif
  7.     uint32_t stride,
  8.     uint32_t offset)
  9. {
  10.     assert(m_currentVertexArray != NULL);
  11.     assert(m_currentArrayBuffer != NULL);
  12.  
  13.     TVertexAttribute attr(TAttributeType::Float, count, stride, offset);
  14.     m_currentVertexArray->AddVertexAttribBinding(index, m_currentArrayBuffer, attr);
  15. }

1.7 测试

实现了以上接口后,我们就可以通过这些接口设置我们的数据内容了。如代码清单 1.7 所示,和 OpenGL 的使用是一样的,我们创建 3 个 VBO,分别存储三角形的顶点、颜色和 UV 坐标。创建了 1 个 VAO 记录这些 VBO。同时创建了一个 EBO,并进行了绑定。

代码清单 1.7 测试
  1. TTriangleOGLPipelineRenderTask::TTriangleOGLPipelineRenderTask(TBasicWindow& win)
  2. {
  3.     float vertices[] = {
  4.         -0.5f, -0.5f, 0.0f,
  5.         -0.5f, 0.5f, 0.0f,
  6.         0.5f, -0.5f, 0.0f
  7.     };
  8.  
  9.     float colors[] = {
  10.         1.0f, 0.0f, 0.0f, 1.0f,
  11.         0.0f, 1.0f, 0.0f, 1.0f,
  12.         0.0f, 0.0f, 1.0f, 1.0f,
  13.     };
  14.  
  15.     float uvs[] = {
  16.         0.0f, 0.0f,
  17.         0.0f, 1.0f,
  18.         1.0f, 0.0f
  19.     };
  20.  
  21.     uint32_t indices[] = {
  22.         0, 1, 2
  23.     };
  24.  
  25.     TSoftRenderer& sr = win.GetRenderer();
  26.  
  27.     uint32_t vao, vboPosition, vboColor, vboUv, ebo;
  28.     sr.GenVertexArrays(1, &vao);
  29.     sr.BindVertexArray(vao);
  30.  
  31.     sr.GenBuffers(1, &vboPosition);
  32.     sr.BindBuffer(TBufferType::ArrayBuffer, vboPosition);
  33.     sr.BufferData(TBufferType::ArrayBuffer, sizeof(vertices), vertices);
  34.     sr.VertexAttribPointer(0, 3, 3 * sizeof(float), 0);
  35.  
  36.     sr.GenBuffers(1, &vboColor);
  37.     sr.BindBuffer(TBufferType::ArrayBuffer, vboColor);
  38.     sr.BufferData(TBufferType::ArrayBuffer, sizeof(colors), colors);
  39.     sr.VertexAttribPointer(1, 4, 4 * sizeof(float), 0);
  40.  
  41.     sr.GenBuffers(1, &vboUv);
  42.     sr.BindBuffer(TBufferType::ArrayBuffer, vboUv);
  43.     sr.BufferData(TBufferType::ArrayBuffer, sizeof(uvs), uvs);
  44.     sr.VertexAttribPointer(2, 2, 2 * sizeof(float), 0);
  45.  
  46.     sr.GenBuffers(1, &ebo);
  47.     sr.BindBuffer(TBufferType::ElementArrayBuffer, ebo);
  48.     sr.BufferData(TBufferType::ElementArrayBuffer, sizeof(indices), indices);
  49.  
  50.     sr.PrintVAO(vao);
  51. }

代码里还实现了 VAO 信息的打印,可以打印信息进行核对调试。代码清单 1.7 对应的打印内容如下:

  • VertexArray ID: 1
  • ----------------------------------------
  •   Attrib Index: 0
  •   Type: 0
  •   Count: 3
  •   Stride: 12
  •   Offset: 0
  •   Bound Buffer: Buffer ID: 1, Size: 36 bytes.
  • ----------------------------------------
  •   Attrib Index: 1
  •   Type: 0
  •   Count: 4
  •   Stride: 16
  •   Offset: 0
  •   Bound Buffer: Buffer ID: 2, Size: 48 bytes.
  • ----------------------------------------
  •   Attrib Index: 2
  •   Type: 0
  •   Count: 2
  •   Stride: 8
  •   Offset: 0
  •   Bound Buffer: Buffer ID: 3, Size: 24 bytes.
  • ----------------------------------------

这节的完整代码在提交 99385b1: Introduce and implement VAO/VBO

2. 着色器类

我们也仿照 OpenGL 着色器的概念,抽象顶点和片元的处理。我们不用实现编译器那套这么复杂,如代码清单 2.1 所示,我们只要实现我们具体的顶点处理和片元处理函数即可。

代码清单 2.1 着色器类
  1. class TShader
  2. {
  3. public:
  4.     virtual ~TShader();
  5.     virtual void VertexShader(const TShaderContext& context, TVertexShaderOutput& output) = 0;
  6.     virtual void FragmentShader(const TVertexShaderOutput& input, TFragmentShaderOutput& output) = 0;
  7. };

具体的顶点信息需要在 VAO 结构里进行查询,但是我不想把 VAO 以及 VBO 这些数据结构暴露给着色器。所以为了封装 VAO 结构,如代码清单 2.2 中的那样,我们定义了一个 Shader Context 类。其中的 GetAttribute 函数,是想模拟 GLSL 中的 layout location 变量定义,它会返回具体的 VBO 内容。

  • layout(location = 0) in vec3 aPos;
  • layout(location = 1) in vec3 aColor;
代码清单 2.2 Shader Context
  1. class TShaderContext
  2. {
  3. public:
  4.     TShaderContext(const TVertexArrayObject* vao);
  5.  
  6.     template<typename T>
  7.     void GetAttribute(uint32_t location, T& out) const
  8.     {
  9.         const TVertexAttribBinding* binding = m_vao->GetVertexAttribBinding(location);
  10.  
  11.         uint32_t stride = binding->attribute.stride;
  12.         uint32_t offset = stride * m_currentVertexIndex + binding->attribute.offset;
  13.  
  14.         uint8_t* buffer = (uint8_t*)binding->buffer->GetBufferData() + offset;
  15.  
  16.         memcpy(&out, buffer, sizeof(T));
  17.     }
  18.  
  19.     void SetVertexIndex(uint32_t index);
  20. private:
  21.     const TVertexArrayObject* m_vao;
  22.     uint32_t m_currentVertexIndex;
  23. };

关于着色器的输入和输出,我们的定义如代码清单 2.3 所示。在着色器函数中,我们只需要查询或填充它们即可。

代码清单 2.3 Input/Output
  1. struct TVertexShaderOutput
  2. {
  3.     tmath::Vec4f position;
  4.  
  5.     bool useColor;
  6.     tmath::Vec4f color;
  7.  
  8.     bool useUV;
  9.     tmath::Vec2f uv;
  10. };
  11.  
  12. struct TFragmentShaderOutput
  13. {
  14.     tmath::Vec4f color;
  15. };

2.1 Simple Shader

定义好着色器类之后,如代码清单 2.4 所示,我们实现一个最简单的着色器,它只进行 MVP 操作。

里面的公共成员就看成 uniform 变量。方便设置。

代码清单 2.4 Simple Shader
  1. class TSimpleShader : public TShader
  2. {
  3. public:
  4.     virtual void VertexShader(const TShaderContext& context, TVertexShaderOutput& output) override;
  5.     virtual void FragmentShader(const TVertexShaderOutput& input, TFragmentShaderOutput& output) override;
  6.  
  7. public:
  8.     tmath::Mat4f modelMatrix;
  9.     tmath::Mat4f viewMatrix;
  10.     tmath::Mat4f projectionMatrix;
  11. };

具体的顶点着色器和片元着色器实现,如代码清单 2.5 所示,其中顶点进行 MVP 变换操作,颜色信息不做改变。

代码清单 2.5 VS/FS
  1. void TSimpleShader::VertexShader(const TShaderContext& context, TVertexShaderOutput& output)
  2. {
  3.     tmath::Vec3f position;
  4.     context.GetAttribute(0, position);
  5.  
  6.     output.position = projectionMatrix * viewMatrix * modelMatrix * tmath::Vec4f(position, 1.0f);
  7.  
  8.     output.useColor = true;
  9.     context.GetAttribute(1, output.color);
  10. }
  11.  
  12. void TSimpleShader::FragmentShader(const TVertexShaderOutput& input, TFragmentShaderOutput& output)
  13. {
  14.     output.color = input.color;
  15. }

3. 渲染管线

我们先回顾一下 glDrawElements 接口。参数 mode 指定要渲染的图元类型;参数 count 指定要渲染的索引数量;参数 type 指定索引的类型;indices 指定索引数组,如果已经有了索引缓冲区,则定义为相对于索引缓冲区的偏移。

  • void glDrawElements(
  •     GLenum mode,
  •     GLsizei count,
  •     GLenum type,
  •     const void* indices);

如代码清单 3.1 所示,我们实现了 DrawElements,其中实现固定管线部分,并调用着色器实现。具体的流程为,在 EBO 中索引图元顶点信息,然后调用顶点着色器,之后进行透视除法,并将 NDC 坐标转化为屏幕坐标,接着进行光栅化。

代码清单 3.1 DrawElements
  1. void TSoftRenderer::DrawElements(
  2.     TDrawMode mode,
  3.     uint32_t size,
  4. #if 0
  5.     TIndexDataType type,
  6. #endif
  7.     uint32_t offset)
  8. {
  9.     uint32_t* indexData = (uint32_t*)(m_currentElementBuffer->GetBufferData() + offset);
  10.  
  11.     FragmentShaderFunction fragFunc = std::bind(&TShader::FragmentShader, m_currentShader, std::placeholders::_1, std::placeholders::_2);
  12.     TShaderContext context(m_currentVertexArray);
  13.     TVertexShaderOutput vertexOutputs[3];
  14.     int primitive = GetPrimitiveCount(mode);
  15.  
  16.     for (uint32_t i = 0; i < size; i += primitive)
  17.     {
  18.         for (uint32_t j = 0; j < primitive; j++)
  19.         {
  20.             context.SetVertexIndex(indexData[i + j]);
  21.  
  22.             // VertexShader
  23.             m_currentShader->VertexShader(context, vertexOutputs[j]);
  24.  
  25.             // 透视除法
  26.             vertexOutputs[j].position /= vertexOutputs[j].position.w();
  27.  
  28.             // NDC - 屏幕
  29.             vertexOutputs[j].position = m_screenMatrix * vertexOutputs[j].position;
  30.         }
  31.  
  32.         switch (mode)
  33.         {
  34.         case TDrawMode::Triangles:
  35.             m_rz.RasterizeTriangle(vertexOutputs[0], vertexOutputs[1], vertexOutputs[2], fragFunc);
  36.             break;
  37.         case TDrawMode::Lines:
  38.         default:
  39.             assert(0);
  40.             break;
  41.         }
  42.     }
  43. }

片元着色器在光栅化阶段进行逐像素调用。如代码清单 3.2 所示,RasterizeTriangle 函数还是之前画三角形的那套逻辑,只不过是增加了片元着色器的调用。

代码清单 3.2 光栅化
  1. void TRasterizer::RasterizeTriangle(
  2.     const TVertexShaderOutput& v1,
  3.     const TVertexShaderOutput& v2,
  4.     const TVertexShaderOutput& v3,
  5.     FragmentShaderFunction fragShader)
  6. {
  7.     const tmath::Vec2i p1 = { (int)v1.position.x(), (int)v1.position.y() };
  8.     const tmath::Vec2i p2 = { (int)v2.position.x(), (int)v2.position.y() };
  9.     const tmath::Vec2i p3 = { (int)v3.position.x(), (int)v3.position.y() };
  10.  
  11.     int minX = std::min(p1.x(), std::min(p2.x(), p3.x()));
  12.     int maxX = std::max(p1.x(), std::max(p2.x(), p3.x()));
  13.     int minY = std::min(p1.y(), std::min(p2.y(), p3.y()));
  14.     int maxY = std::max(p1.y(), std::max(p2.y(), p3.y()));
  15.  
  16.     tmath::Vec2i p, pp1, pp2, pp3;
  17.     int c1, c2, c3;
  18.     float alpha, beta, gamma;
  19.     float area = (float)std::abs(tmath::cross(p2 - p1, p3 - p1));
  20.  
  21.     TFragmentShaderOutput fragOutput;
  22.     TVertexShaderOutput interpolatedInput;
  23.  
  24.     for (int i = minX; i <= maxX; i++)
  25.     {
  26.         p.x() = i;
  27.         for (int j = minY; j <= maxY; j++)
  28.         {
  29.             p.y() = j;
  30.  
  31.             pp1.x() = p1.x() - p.x(); pp1.y() = p1.y() - p.y(); // pp1 = p1 - p;
  32.             pp2.x() = p2.x() - p.x(); pp2.y() = p2.y() - p.y(); // pp2 = p2 - p;
  33.             pp3.x() = p3.x() - p.x(); pp3.y() = p3.y() - p.y(); // pp3 = p3 - p;
  34.  
  35.             c1 = tmath::cross(pp1, pp2);
  36.             c2 = tmath::cross(pp2, pp3);
  37.             c3 = tmath::cross(pp3, pp1);
  38.  
  39.             if ((c1 >= 0 && c2 >= 0 && c3 >= 0) ||
  40.                 (c1 <= 0 && c2 <= 0 && c3 <= 0))
  41.             {
  42.                 alpha = std::abs(c2) / area;
  43.                 beta = std::abs(c3) / area;
  44.                 gamma = std::abs(c1) / area;
  45.  
  46.                 if (v1.useColor)
  47.                 {
  48.                     interpolatedInput.color = tmath::interpolate(
  49.                         v1.color, alpha,
  50.                         v2.color, beta,
  51.                         v3.color, gamma
  52.                     );
  53.  
  54.                     fragShader(interpolatedInput, fragOutput);
  55.  
  56.                     SetPixel(i, j, TRGBA::FromVec4f(fragOutput.color));
  57.                 }
  58.             }
  59.         }
  60.     }
  61. }

3.1 测试

我们实现一个三角形旋转样例。接着代码清单 1.7 的基础,如代码清单 3.3 所示,我们使用实现的 Simple Shader 类,并设置 MVP 矩阵。接着使用 UseProgram 函数,设置着色器。渲染过程中,我们不断更新旋转矩阵,并调用 DrawElements 函数进行绘制。

代码清单 3.3 旋转三角形
  1. TTriangleOGLPipelineRenderTask::TTriangleOGLPipelineRenderTask(TBasicWindow& win)
  2.     : m_angle(0)
  3. {
  4.     float vertices[] = {
  5.         -0.5f, -0.5f, 0.0f,
  6.         -0.5f, 0.5f, 0.0f,
  7.         0.5f, -0.5f, 0.0f
  8.     };
  9.  
  10.     float colors[] = {
  11.         1.0f, 0.0f, 0.0f, 1.0f,
  12.         0.0f, 1.0f, 0.0f, 1.0f,
  13.         0.0f, 0.0f, 1.0f, 1.0f,
  14.     };
  15.  
  16.     float uvs[] = {
  17.         0.0f, 0.0f,
  18.         0.0f, 1.0f,
  19.         1.0f, 0.0f
  20.     };
  21.  
  22.     uint32_t indices[] = {
  23.         0, 1, 2
  24.     };
  25.  
  26.     TSoftRenderer& sr = win.GetRenderer();
  27.  
  28.     uint32_t vao, vboPosition, vboColor, vboUv, ebo;
  29.     sr.GenVertexArrays(1, &vao);
  30.     sr.BindVertexArray(vao);
  31.  
  32.     sr.GenBuffers(1, &vboPosition);
  33.     sr.BindBuffer(TBufferType::ArrayBuffer, vboPosition);
  34.     sr.BufferData(TBufferType::ArrayBuffer, sizeof(vertices), vertices);
  35.     sr.VertexAttribPointer(0, 3, 3 * sizeof(float), 0);
  36.  
  37.     sr.GenBuffers(1, &vboColor);
  38.     sr.BindBuffer(TBufferType::ArrayBuffer, vboColor);
  39.     sr.BufferData(TBufferType::ArrayBuffer, sizeof(colors), colors);
  40.     sr.VertexAttribPointer(1, 4, 4 * sizeof(float), 0);
  41.  
  42.     sr.GenBuffers(1, &vboUv);
  43.     sr.BindBuffer(TBufferType::ArrayBuffer, vboUv);
  44.     sr.BufferData(TBufferType::ArrayBuffer, sizeof(uvs), uvs);
  45.     sr.VertexAttribPointer(2, 2, 2 * sizeof(float), 0);
  46.  
  47.     sr.GenBuffers(1, &ebo);
  48.     sr.BindBuffer(TBufferType::ElementArrayBuffer, ebo);
  49.     sr.BufferData(TBufferType::ElementArrayBuffer, sizeof(indices), indices);
  50.  
  51.     sr.PrintVAO(vao);
  52.  
  53.     ////
  54.     int width = win.GetWindowWidth();
  55.     int height = win.GetWindowHeight();
  56.  
  57.     float aspect = (float)width / height;
  58.  
  59.     m_shader.projectionMatrix = tmath::PerspectiveMatrix(tmath::degToRad(60.0f), aspect, 0.1f, 100.0f);
  60.     m_shader.viewMatrix = tmath::TranslationMatrix(0.0f, 0.0f, 3.0f);
  61.  
  62.     sr.UseProgram(&m_shader);
  63. }
  64.  
  65. void TTriangleOGLPipelineRenderTask::Render(TSoftRenderer& sr)
  66. {
  67.     Transform();
  68.  
  69.     sr.Clear({ 0,0,0 });
  70.  
  71.     sr.DrawElements(TDrawMode::Triangles, 3, 0);
  72. }
  73.  
  74. void TTriangleOGLPipelineRenderTask::Transform()
  75. {
  76.     m_angle -= 0.01f;
  77.     m_shader.modelMatrix = tmath::RotationMatrix(tmath::Vec3f(0.0f, 1.0f, 0.0f), m_angle);
  78. }

最终的显示效果如视频 1 所示。

视频 1 旋转三角形

本篇文章对应的完整代码在 tag/ogl_pipeline

4. 左手坐标系?右手坐标系?

关注坐标系的“契机”是,自己实现的旋转三角形的位置以及旋转方向都和样例不同。确定了不是管线逻辑这部分实现差异引起的,所以排查范围落到了 MPV 这套矩阵的定义。

样例中使用的是右手坐标系,而我在之前公式的推导中,发现左手坐标系推导更自然,所以代码中一直使用的左手坐标系。但这就导致了我不能对照样例程序逐帧调试差异点了。

排查这个问题的“指导方针”是,无论是左手坐标系,还是右手坐标系,如图 1 所示,在“上帝视角”观察,只要相机位置和物体位置是一样的,那么显示的效果就一定是一样的。

图1 左手坐标系和右手坐标系

也就是说,要想显示效果一样,最终的 MVP 矩阵内容一定要是一样的。

这就是 NDC 空间的意义。

所以需要依次核对排查 M、V、P 矩阵的实现是否有误。我这边不确定自己左手坐标系的推导是否正确,对比了开源库 glm 的实现。如代码清单 4 所示,glm 中有透视投影矩阵的左右手坐标系实现,可以查看源码实现。

代码清单 4 glm
  1. #include <glm/glm.hpp>
  2. #include <glm/gtc/matrix_transform.hpp>
  3. #include <glm/gtc/type_ptr.hpp>
  4. #include <iostream>
  5.  
  6. glm::mat4 createLeftHandedMVP() {
  7.     glm::mat4 projection = glm::perspectiveLH(glm::radians(60.0f), 8.0f / 6.0f, 0.1f, 1000.0f);
  8.     glm::mat4 view = glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, -3.0f));
  9.     glm::mat4 model = glm::rotate(glm::mat4(1.0f), glm::radians(270.0f), glm::vec3(1.0f, 2.0f, -3.0f));
  10.     glm::mat4 mvp = projection * view * model;
  11.     return mvp;
  12. }
  13.  
  14. glm::mat4 createRightHandedMVP() {
  15.     glm::mat4 projection = glm::perspectiveRH(glm::radians(60.0f), 8.0f / 6.0f, 0.1f, 1000.0f);
  16.     glm::mat4 view = glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, 3.0f));
  17.     glm::mat4 model = glm::rotate(glm::mat4(1.0f), glm::radians(-270.0f), glm::vec3(1.0f, 2.0f, 3.0f));
  18.     glm::mat4 mvp = projection * view * model;
  19.     return mvp;
  20. }
  21.  
  22. void printMatrix(const glm::mat4& mat) {
  23.     for (int i = 0; i < 4; i++) {
  24.         for (int j = 0; j < 4; j++) {
  25.             std::cout << mat[j][i] << " ";
  26.         }
  27.         std::cout << "\n";
  28.     }
  29.     std::cout << "\n";
  30. }
  31.  
  32. int main() {
  33.     glm::mat4 lh = createLeftHandedMVP();
  34.     printMatrix(lh);
  35.     glm::mat4 rh = createRightHandedMVP();
  36.     printMatrix(rh);
  37. }

视图矩阵左右手坐标系也要区分,关键是定位好相机的位置和朝向就可以。旋转矩阵也有差异,相同的旋转矩阵,因为 z 轴的方向不同,最终的物体效果变化也是不一样的。这就是旋转方向不一致的原因。

单就针对旋转矩阵,在左右手坐标系中,要想旋转效果一样,不仅要定位好旋转轴,旋转角度也要相反(因为 z 轴相反)。可以通过代码清单 4 验证自己的想法。

最后排查到显示不一致的原因:

一是左手坐标系透视投影矩阵实现有一处笔误(之前文章中的推导过程和结果都是对的,是抄写的笔误);

二是对空间变换的理解不足,特别是旋转矩阵这块造成的效果差异。

这个问题,可以加深对空间变换的理解。

之前听到过 OpenGL 是左手坐标系,还是右手坐标系的问题?可以看到两者都可以,主要是你自己要清楚你想得到什么效果,自己心里对变换操作有数。因为无论是左手坐标系,还是右手坐标系,肯定都能达到一样的效果。