渲染管线
这篇文章的实验内容,我觉得很有意思,我们将模拟 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 的名称、大小和分配的地址。
- class TBufferObject
- {
- public:
- TBufferObject(uint32_t id);
- ~TBufferObject();
- void SetBufferData(uint32_t size, void* data);
- uint8_t* GetBufferData();
- /* Debug */
- void Print() const;
- private:
- uint32_t m_id;
- uint32_t m_size;
- uint8_t* m_pBuffer;
- };
如代码清单 1.1.2 所示,BufferObject 初始化时不分配大小,而是使用 SetBufferData 接口分配 buffer 的大小以及具体的内容。
- TBufferObject::TBufferObject(uint32_t id)
- : m_id(id),
- m_size(0),
- m_pBuffer(NULL)
- {
- }
- TBufferObject::~TBufferObject()
- {
- delete[] m_pBuffer;
- }
- void TBufferObject::SetBufferData(uint32_t size, void* data)
- {
- if (size > m_size)
- {
- delete[] m_pBuffer;
- m_pBuffer = new uint8_t[size];
- }
- m_size = size;
- if (data != NULL && size > 0)
- {
- memcpy(m_pBuffer, data, size);
- }
- }
buffer object 实现完成后,我们就可以着手实现 glGenBuffers 接口了。如代码清单 1.1.3 所示,我们实现了 GenBuffers 接口。buffer 名称依次递增,但是释放了的名称,还能“回收利用”。内部使用 map 表,记录 buffer 名称和实际 buffer object 的映射。
- class TSoftRenderer
- {
- /* VBO */
- std::unordered_map<uint32_t, TBufferObject*> m_bufferMap;
- std::queue<uint32_t> m_freeBufferIds;
- uint32_t m_nextBufferId;
- };
- uint32_t TSoftRenderer::AllocateBufferId()
- {
- if (m_freeBufferIds.empty())
- {
- return ++m_nextBufferId;
- }
- else
- {
- uint32_t id = m_freeBufferIds.front();
- m_freeBufferIds.pop();
- return id;
- }
- }
- void TSoftRenderer::GenBuffers(uint32_t n, uint32_t* buffers)
- {
- for (uint32_t i = 0; i < n; i++)
- {
- uint32_t id = AllocateBufferId();
- m_bufferMap[id] = new TBufferObject(id);
- buffers[i] = id;
- }
- }
接口对应的 delete 接口实现都很简单,文章中都不赘述。
1.2 glBindBuffer
回顾 glBindBuffer 接口。参数 target 指定缓冲区对象绑定的目标;参数 buffer 指定要绑定的缓冲区名称。
- void glBindBuffer(GLenum target, GLuint buffer);
绑定目标的话,目前我们只使用到 GL_ARRAY_BUFFER 和 GL_ELEMENT_ARRAY_BUFFER。它们分别表示顶点属性的缓冲区和顶点索引的缓冲区。所以如代码清单 1.2.1 所示,我们对这两个目标进行定义。
- enum class TBufferType
- {
- ArrayBuffer,
- ElementArrayBuffer
- };
BindBuffer 的逻辑不复杂,如代码清单 1.2.2 所示,就是状态的记录。在内部,我们记录绑定的 VBO 和 EBO。
- class TSoftRenderer
- {
- /* Bounded */
- TBufferObject* m_currentArrayBuffer;
- TBufferObject* m_currentElementBuffer;
- };
- void TSoftRenderer::BindBuffer(TBufferType target, uint32_t buffer)
- {
- TBufferObject* bufferPtr = NULL;
- if (buffer != 0)
- {
- auto it = m_bufferMap.find(buffer);
- assert(it != m_bufferMap.end());
- bufferPtr = it->second;
- }
- switch (target)
- {
- case TBufferType::ArrayBuffer:
- m_currentArrayBuffer = bufferPtr;
- break;
- case TBufferType::ElementArrayBuffer:
- m_currentElementBuffer = bufferPtr;
- break;
- default:
- assert(0);
- break;
- }
- }
缓冲区名称传递为 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 接口。
- void TSoftRenderer::BufferData(TBufferType target, uint32_t size, void* data)
- {
- TBufferObject* buffer = NULL;
- switch (target)
- {
- case TBufferType::ArrayBuffer:
- buffer = m_currentArrayBuffer;
- break;
- case TBufferType::ElementArrayBuffer:
- buffer = m_currentElementBuffer;
- break;
- default:
- break;
- }
- assert(buffer != NULL);
- buffer->SetBufferData(size, data);
- }
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 的顶点属性信息。
- enum class TAttributeType
- {
- Float
- };
- struct TVertexAttribute
- {
- TAttributeType type;
- uint32_t count;
- uint32_t stride;
- uint32_t offset;
- TVertexAttribute();
- TVertexAttribute(TAttributeType type, uint32_t count, uint32_t stride, uint32_t offset);
- };
- struct TVertexAttribBinding
- {
- TBufferObject* buffer;
- TVertexAttribute attribute;
- TVertexAttribBinding();
- TVertexAttribBinding(TBufferObject* buffer, TVertexAttribute attribute);
- };
- class TVertexArrayObject
- {
- public:
- TVertexArrayObject(uint32_t id);
- void AddVertexAttribBinding(uint32_t index, TBufferObject* buffer, const TVertexAttribute& attribute);
- const TVertexAttribBinding* GetVertexAttribBinding(uint32_t index) const;
- /* Debug */
- void Print() const;
- private:
- uint32_t m_id;
- std::unordered_map<uint32_t, TVertexAttribBinding> m_bindings;
- };
GenVertexArrays 的实现和 GenBuffers 的逻辑是一模一样的。贴出代码清单 1.4.2 中定义的相关变量,就能“脑补”出实现,这边不再赘述。
- class TSoftRenderer
- {
- /* VAO */
- std::unordered_map<uint32_t, TVertexArrayObject*> m_vaoMap;
- std::queue<uint32_t> m_freeVaoIds;
- uint32_t m_nextVaoId;
- uint32_t AllocateVaoId();
- };
1.5 glBindVertexArray
回顾 glBindVertexArray 接口。参数 array 指定要绑定的顶点数组对象的名称。
- void glBindVertexArray(GLuint array);
BindVertexArray 的实现方式和 BindBuffer 一样。如代码清单 1.5 所示,我们内部记录绑定的 VAO。
- class TSoftRenderer
- {
- /* Bounded */
- TVertexArrayObject* m_currentVertexArray;
- };
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 和顶点属性进行映射。
- void TSoftRenderer::VertexAttribPointer(
- uint32_t index,
- uint32_t count,
- #if 0
- TAttributeType type,
- #endif
- uint32_t stride,
- uint32_t offset)
- {
- assert(m_currentVertexArray != NULL);
- assert(m_currentArrayBuffer != NULL);
- TVertexAttribute attr(TAttributeType::Float, count, stride, offset);
- m_currentVertexArray->AddVertexAttribBinding(index, m_currentArrayBuffer, attr);
- }
1.7 测试
实现了以上接口后,我们就可以通过这些接口设置我们的数据内容了。如代码清单 1.7 所示,和 OpenGL 的使用是一样的,我们创建 3 个 VBO,分别存储三角形的顶点、颜色和 UV 坐标。创建了 1 个 VAO 记录这些 VBO。同时创建了一个 EBO,并进行了绑定。
- TTriangleOGLPipelineRenderTask::TTriangleOGLPipelineRenderTask(TBasicWindow& win)
- {
- float vertices[] = {
- -0.5f, -0.5f, 0.0f,
- -0.5f, 0.5f, 0.0f,
- 0.5f, -0.5f, 0.0f
- };
- float colors[] = {
- 1.0f, 0.0f, 0.0f, 1.0f,
- 0.0f, 1.0f, 0.0f, 1.0f,
- 0.0f, 0.0f, 1.0f, 1.0f,
- };
- float uvs[] = {
- 0.0f, 0.0f,
- 0.0f, 1.0f,
- 1.0f, 0.0f
- };
- uint32_t indices[] = {
- 0, 1, 2
- };
- TSoftRenderer& sr = win.GetRenderer();
- uint32_t vao, vboPosition, vboColor, vboUv, ebo;
- sr.GenVertexArrays(1, &vao);
- sr.BindVertexArray(vao);
- sr.GenBuffers(1, &vboPosition);
- sr.BindBuffer(TBufferType::ArrayBuffer, vboPosition);
- sr.BufferData(TBufferType::ArrayBuffer, sizeof(vertices), vertices);
- sr.VertexAttribPointer(0, 3, 3 * sizeof(float), 0);
- sr.GenBuffers(1, &vboColor);
- sr.BindBuffer(TBufferType::ArrayBuffer, vboColor);
- sr.BufferData(TBufferType::ArrayBuffer, sizeof(colors), colors);
- sr.VertexAttribPointer(1, 4, 4 * sizeof(float), 0);
- sr.GenBuffers(1, &vboUv);
- sr.BindBuffer(TBufferType::ArrayBuffer, vboUv);
- sr.BufferData(TBufferType::ArrayBuffer, sizeof(uvs), uvs);
- sr.VertexAttribPointer(2, 2, 2 * sizeof(float), 0);
- sr.GenBuffers(1, &ebo);
- sr.BindBuffer(TBufferType::ElementArrayBuffer, ebo);
- sr.BufferData(TBufferType::ElementArrayBuffer, sizeof(indices), indices);
- sr.PrintVAO(vao);
- }
代码里还实现了 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 所示,我们只要实现我们具体的顶点处理和片元处理函数即可。
- class TShader
- {
- public:
- virtual ~TShader();
- virtual void VertexShader(const TShaderContext& context, TVertexShaderOutput& output) = 0;
- virtual void FragmentShader(const TVertexShaderOutput& input, TFragmentShaderOutput& output) = 0;
- };
具体的顶点信息需要在 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;
- class TShaderContext
- {
- public:
- TShaderContext(const TVertexArrayObject* vao);
- template<typename T>
- void GetAttribute(uint32_t location, T& out) const
- {
- const TVertexAttribBinding* binding = m_vao->GetVertexAttribBinding(location);
- uint32_t stride = binding->attribute.stride;
- uint32_t offset = stride * m_currentVertexIndex + binding->attribute.offset;
- uint8_t* buffer = (uint8_t*)binding->buffer->GetBufferData() + offset;
- memcpy(&out, buffer, sizeof(T));
- }
- void SetVertexIndex(uint32_t index);
- private:
- const TVertexArrayObject* m_vao;
- uint32_t m_currentVertexIndex;
- };
关于着色器的输入和输出,我们的定义如代码清单 2.3 所示。在着色器函数中,我们只需要查询或填充它们即可。
- struct TVertexShaderOutput
- {
- tmath::Vec4f position;
- bool useColor;
- tmath::Vec4f color;
- bool useUV;
- tmath::Vec2f uv;
- };
- struct TFragmentShaderOutput
- {
- tmath::Vec4f color;
- };
2.1 Simple Shader
定义好着色器类之后,如代码清单 2.4 所示,我们实现一个最简单的着色器,它只进行 MVP 操作。
里面的公共成员就看成 uniform 变量。方便设置。
- class TSimpleShader : public TShader
- {
- public:
- virtual void VertexShader(const TShaderContext& context, TVertexShaderOutput& output) override;
- virtual void FragmentShader(const TVertexShaderOutput& input, TFragmentShaderOutput& output) override;
- public:
- tmath::Mat4f modelMatrix;
- tmath::Mat4f viewMatrix;
- tmath::Mat4f projectionMatrix;
- };
具体的顶点着色器和片元着色器实现,如代码清单 2.5 所示,其中顶点进行 MVP 变换操作,颜色信息不做改变。
- void TSimpleShader::VertexShader(const TShaderContext& context, TVertexShaderOutput& output)
- {
- tmath::Vec3f position;
- context.GetAttribute(0, position);
- output.position = projectionMatrix * viewMatrix * modelMatrix * tmath::Vec4f(position, 1.0f);
- output.useColor = true;
- context.GetAttribute(1, output.color);
- }
- void TSimpleShader::FragmentShader(const TVertexShaderOutput& input, TFragmentShaderOutput& output)
- {
- output.color = input.color;
- }
3. 渲染管线
我们先回顾一下 glDrawElements 接口。参数 mode 指定要渲染的图元类型;参数 count 指定要渲染的索引数量;参数 type 指定索引的类型;indices 指定索引数组,如果已经有了索引缓冲区,则定义为相对于索引缓冲区的偏移。
- void glDrawElements(
- GLenum mode,
- GLsizei count,
- GLenum type,
- const void* indices);
如代码清单 3.1 所示,我们实现了 DrawElements,其中实现固定管线部分,并调用着色器实现。具体的流程为,在 EBO 中索引图元顶点信息,然后调用顶点着色器,之后进行透视除法,并将 NDC 坐标转化为屏幕坐标,接着进行光栅化。
- void TSoftRenderer::DrawElements(
- TDrawMode mode,
- uint32_t size,
- #if 0
- TIndexDataType type,
- #endif
- uint32_t offset)
- {
- uint32_t* indexData = (uint32_t*)(m_currentElementBuffer->GetBufferData() + offset);
- FragmentShaderFunction fragFunc = std::bind(&TShader::FragmentShader, m_currentShader, std::placeholders::_1, std::placeholders::_2);
- TShaderContext context(m_currentVertexArray);
- TVertexShaderOutput vertexOutputs[3];
- int primitive = GetPrimitiveCount(mode);
- for (uint32_t i = 0; i < size; i += primitive)
- {
- for (uint32_t j = 0; j < primitive; j++)
- {
- context.SetVertexIndex(indexData[i + j]);
- // VertexShader
- m_currentShader->VertexShader(context, vertexOutputs[j]);
- // 透视除法
- vertexOutputs[j].position /= vertexOutputs[j].position.w();
- // NDC - 屏幕
- vertexOutputs[j].position = m_screenMatrix * vertexOutputs[j].position;
- }
- switch (mode)
- {
- case TDrawMode::Triangles:
- m_rz.RasterizeTriangle(vertexOutputs[0], vertexOutputs[1], vertexOutputs[2], fragFunc);
- break;
- case TDrawMode::Lines:
- default:
- assert(0);
- break;
- }
- }
- }
片元着色器在光栅化阶段进行逐像素调用。如代码清单 3.2 所示,RasterizeTriangle 函数还是之前画三角形的那套逻辑,只不过是增加了片元着色器的调用。
- void TRasterizer::RasterizeTriangle(
- const TVertexShaderOutput& v1,
- const TVertexShaderOutput& v2,
- const TVertexShaderOutput& v3,
- FragmentShaderFunction fragShader)
- {
- const tmath::Vec2i p1 = { (int)v1.position.x(), (int)v1.position.y() };
- const tmath::Vec2i p2 = { (int)v2.position.x(), (int)v2.position.y() };
- const tmath::Vec2i p3 = { (int)v3.position.x(), (int)v3.position.y() };
- int minX = std::min(p1.x(), std::min(p2.x(), p3.x()));
- int maxX = std::max(p1.x(), std::max(p2.x(), p3.x()));
- int minY = std::min(p1.y(), std::min(p2.y(), p3.y()));
- int maxY = std::max(p1.y(), std::max(p2.y(), p3.y()));
- tmath::Vec2i p, pp1, pp2, pp3;
- int c1, c2, c3;
- float alpha, beta, gamma;
- float area = (float)std::abs(tmath::cross(p2 - p1, p3 - p1));
- TFragmentShaderOutput fragOutput;
- TVertexShaderOutput interpolatedInput;
- for (int i = minX; i <= maxX; i++)
- {
- p.x() = i;
- for (int j = minY; j <= maxY; j++)
- {
- p.y() = j;
- pp1.x() = p1.x() - p.x(); pp1.y() = p1.y() - p.y(); // pp1 = p1 - p;
- pp2.x() = p2.x() - p.x(); pp2.y() = p2.y() - p.y(); // pp2 = p2 - p;
- pp3.x() = p3.x() - p.x(); pp3.y() = p3.y() - p.y(); // pp3 = p3 - p;
- c1 = tmath::cross(pp1, pp2);
- c2 = tmath::cross(pp2, pp3);
- c3 = tmath::cross(pp3, pp1);
- if ((c1 >= 0 && c2 >= 0 && c3 >= 0) ||
- (c1 <= 0 && c2 <= 0 && c3 <= 0))
- {
- alpha = std::abs(c2) / area;
- beta = std::abs(c3) / area;
- gamma = std::abs(c1) / area;
- if (v1.useColor)
- {
- interpolatedInput.color = tmath::interpolate(
- v1.color, alpha,
- v2.color, beta,
- v3.color, gamma
- );
- fragShader(interpolatedInput, fragOutput);
- SetPixel(i, j, TRGBA::FromVec4f(fragOutput.color));
- }
- }
- }
- }
- }
3.1 测试
我们实现一个三角形旋转样例。接着代码清单 1.7 的基础,如代码清单 3.3 所示,我们使用实现的 Simple Shader 类,并设置 MVP 矩阵。接着使用 UseProgram 函数,设置着色器。渲染过程中,我们不断更新旋转矩阵,并调用 DrawElements 函数进行绘制。
- TTriangleOGLPipelineRenderTask::TTriangleOGLPipelineRenderTask(TBasicWindow& win)
- : m_angle(0)
- {
- float vertices[] = {
- -0.5f, -0.5f, 0.0f,
- -0.5f, 0.5f, 0.0f,
- 0.5f, -0.5f, 0.0f
- };
- float colors[] = {
- 1.0f, 0.0f, 0.0f, 1.0f,
- 0.0f, 1.0f, 0.0f, 1.0f,
- 0.0f, 0.0f, 1.0f, 1.0f,
- };
- float uvs[] = {
- 0.0f, 0.0f,
- 0.0f, 1.0f,
- 1.0f, 0.0f
- };
- uint32_t indices[] = {
- 0, 1, 2
- };
- TSoftRenderer& sr = win.GetRenderer();
- uint32_t vao, vboPosition, vboColor, vboUv, ebo;
- sr.GenVertexArrays(1, &vao);
- sr.BindVertexArray(vao);
- sr.GenBuffers(1, &vboPosition);
- sr.BindBuffer(TBufferType::ArrayBuffer, vboPosition);
- sr.BufferData(TBufferType::ArrayBuffer, sizeof(vertices), vertices);
- sr.VertexAttribPointer(0, 3, 3 * sizeof(float), 0);
- sr.GenBuffers(1, &vboColor);
- sr.BindBuffer(TBufferType::ArrayBuffer, vboColor);
- sr.BufferData(TBufferType::ArrayBuffer, sizeof(colors), colors);
- sr.VertexAttribPointer(1, 4, 4 * sizeof(float), 0);
- sr.GenBuffers(1, &vboUv);
- sr.BindBuffer(TBufferType::ArrayBuffer, vboUv);
- sr.BufferData(TBufferType::ArrayBuffer, sizeof(uvs), uvs);
- sr.VertexAttribPointer(2, 2, 2 * sizeof(float), 0);
- sr.GenBuffers(1, &ebo);
- sr.BindBuffer(TBufferType::ElementArrayBuffer, ebo);
- sr.BufferData(TBufferType::ElementArrayBuffer, sizeof(indices), indices);
- sr.PrintVAO(vao);
- ////
- int width = win.GetWindowWidth();
- int height = win.GetWindowHeight();
- float aspect = (float)width / height;
- m_shader.projectionMatrix = tmath::PerspectiveMatrix(tmath::degToRad(60.0f), aspect, 0.1f, 100.0f);
- m_shader.viewMatrix = tmath::TranslationMatrix(0.0f, 0.0f, 3.0f);
- sr.UseProgram(&m_shader);
- }
- void TTriangleOGLPipelineRenderTask::Render(TSoftRenderer& sr)
- {
- Transform();
- sr.Clear({ 0,0,0 });
- sr.DrawElements(TDrawMode::Triangles, 3, 0);
- }
- void TTriangleOGLPipelineRenderTask::Transform()
- {
- m_angle -= 0.01f;
- m_shader.modelMatrix = tmath::RotationMatrix(tmath::Vec3f(0.0f, 1.0f, 0.0f), m_angle);
- }
最终的显示效果如视频 1 所示。
本篇文章对应的完整代码在 tag/ogl_pipeline。
4. 左手坐标系?右手坐标系?
关注坐标系的“契机”是,自己实现的旋转三角形的位置以及旋转方向都和样例不同。确定了不是管线逻辑这部分实现差异引起的,所以排查范围落到了 MPV 这套矩阵的定义。
样例中使用的是右手坐标系,而我在之前公式的推导中,发现左手坐标系推导更自然,所以代码中一直使用的左手坐标系。但这就导致了我不能对照样例程序逐帧调试差异点了。
排查这个问题的“指导方针”是,无论是左手坐标系,还是右手坐标系,如图 1 所示,在“上帝视角”观察,只要相机位置和物体位置是一样的,那么显示的效果就一定是一样的。

也就是说,要想显示效果一样,最终的 MVP 矩阵内容一定要是一样的。
这就是 NDC 空间的意义。
所以需要依次核对排查 M、V、P 矩阵的实现是否有误。我这边不确定自己左手坐标系的推导是否正确,对比了开源库 glm 的实现。如代码清单 4 所示,glm 中有透视投影矩阵的左右手坐标系实现,可以查看源码实现。
- #include <glm/glm.hpp>
- #include <glm/gtc/matrix_transform.hpp>
- #include <glm/gtc/type_ptr.hpp>
- #include <iostream>
- glm::mat4 createLeftHandedMVP() {
- glm::mat4 projection = glm::perspectiveLH(glm::radians(60.0f), 8.0f / 6.0f, 0.1f, 1000.0f);
- glm::mat4 view = glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, -3.0f));
- glm::mat4 model = glm::rotate(glm::mat4(1.0f), glm::radians(270.0f), glm::vec3(1.0f, 2.0f, -3.0f));
- glm::mat4 mvp = projection * view * model;
- return mvp;
- }
- glm::mat4 createRightHandedMVP() {
- glm::mat4 projection = glm::perspectiveRH(glm::radians(60.0f), 8.0f / 6.0f, 0.1f, 1000.0f);
- glm::mat4 view = glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, 3.0f));
- glm::mat4 model = glm::rotate(glm::mat4(1.0f), glm::radians(-270.0f), glm::vec3(1.0f, 2.0f, 3.0f));
- glm::mat4 mvp = projection * view * model;
- return mvp;
- }
- void printMatrix(const glm::mat4& mat) {
- for (int i = 0; i < 4; i++) {
- for (int j = 0; j < 4; j++) {
- std::cout << mat[j][i] << " ";
- }
- std::cout << "\n";
- }
- std::cout << "\n";
- }
- int main() {
- glm::mat4 lh = createLeftHandedMVP();
- printMatrix(lh);
- glm::mat4 rh = createRightHandedMVP();
- printMatrix(rh);
- }
视图矩阵左右手坐标系也要区分,关键是定位好相机的位置和朝向就可以。旋转矩阵也有差异,相同的旋转矩阵,因为 z 轴的方向不同,最终的物体效果变化也是不一样的。这就是旋转方向不一致的原因。
单就针对旋转矩阵,在左右手坐标系中,要想旋转效果一样,不仅要定位好旋转轴,旋转角度也要相反(因为 z 轴相反)。可以通过代码清单 4 验证自己的想法。
最后排查到显示不一致的原因:
一是左手坐标系透视投影矩阵实现有一处笔误(之前文章中的推导过程和结果都是对的,是抄写的笔误);
二是对空间变换的理解不足,特别是旋转矩阵这块造成的效果差异。
这个问题,可以加深对空间变换的理解。
之前听到过 OpenGL 是左手坐标系,还是右手坐标系的问题?可以看到两者都可以,主要是你自己要清楚你想得到什么效果,自己心里对变换操作有数。因为无论是左手坐标系,还是右手坐标系,肯定都能达到一样的效果。