透视校正
现在我们的管线流程还存在一些问题。之前的三角形看着不直观,我们看到视频 1 中的棋盘格显示,可以发现效果是错误的。
错误的原因如图 1 所示 [1],比如我们在透视投影之后得到的插值比例是 0.5,但是透视投影之前对应的插值点,比例不是 0.5。透视投影之后得到的插值是错误的,我们希望把它修正成透视投影之前正确的插值。

1. 透视校正插值
插值错误的本质原因还是透视投影不是线性变换导致的。主要表现在透视除法这步操作,所以我们这边针对透视除法前后进行推导。
直观可以感受到,只要是线性变化,线性重心插值的比例是不会变的。这边不进行证明。
我们设透视除法之前的点,有
\(A(x_1,y_1,z_1,w_1),B(x_2,y_2,z_2,w_2),C(x_3,y_3,z_3,w_3)\)
透视除法之后的点,为
\(A'(\frac{x_1}{w_1},\frac{y_1}{w_1},\frac{z_1}{w_1},1),B'(\frac{x_2}{w_2},\frac{y_2}{w_2},\frac{z_2}{w_2},1),C'(\frac{x_3}{w_3},\frac{y_3}{w_3},\frac{z_3}{w_3},1)\)
目前的流程,我们是根据 \(A',B',C'\) 得到重心插值坐标的,我们有插值点
\(O'=\alpha'A'+\beta'B'+\gamma'C'\)
这边的 \(O'\) 肯定也有对应的透视除法之前的原始点,我们假设它为
\(O=(x_o,y_o,z_o,w_o)=\alpha A+\beta B+\gamma C\)
这样 \(O'\) 就有另一种表达方式
\(O'=\frac{O}{w_o}\)
这种假设是符合直觉的:透视除法前的插值点,对应透视除法之后的插值点。
就算没有实际联系,我们也可以把它看成是人为的映射关系。因为我们现在讨论的问题就是,希望把 \(\alpha'\) 修正成透视投影之前空间里的 \(\alpha\),至于具体修正成哪个点,我们这边进行人为规定。
我是这么说服自己的。
\(O'\) 的两种表达方式,要表达同一个点,变量的系数要一样。我们可以得到关系
\(\frac{\alpha'}{w_1}=\frac{\alpha}{w_o},\frac{\beta'}{w_2}=\frac{\beta}{w_o},\frac{\gamma'}{w_3}=\frac{\gamma}{w_o}\)
现在 \(\alpha'\) 和 \(w_1\) 这些都可以看成常数,要求 \(\alpha\) 这个“变量”。现在遇到的问题就是, \(w_o\) 怎么表示?
根据插值我们有 \(w_o=\alpha w_1+\beta w_2 + \gamma w_3\),但是这条等式里都是代求“变量”,无法利用。
根据 \(\alpha'+\beta'+\gamma'=1\),代入 \(\alpha'=\frac{\alpha w_1}{w_o},\beta'=\frac{\beta w_2}{w_o},\gamma'=\frac{\gamma w_3}{w_o}\)
也能正确得到 \(w_o=\alpha w_1+\beta w_2 + \gamma w_3\)。可以侧面看到假设是合理的。
根据 \(\alpha+\beta+\gamma=1\),代入 \(\alpha=\frac{\alpha'}{w_1}w_o,\beta=\frac{\beta'}{w_2}w_o,\gamma=\frac{\gamma'}{w_3}w_o\)
可以得到 \(\frac{1}{w_o}=\frac{\alpha'}{w_1}+\frac{\beta'}{w_2}+\frac{\gamma'}{w_3}\)
这样,我们的问题就解决了。可以看到形式也很漂亮:透视除法之前 \(w\) 分量的倒数,是满足透视除法之后的插值比例的。
以上推导过程中,“不严谨”的地方有点多(可以看到自己的“旁白注释”非常多)。可以参考文章 [1],里面是纯几何推导,更直观。
看到文章 [1] 里最后的结论公式,和我们推导出的结论是一样的。
\(Z_t=\frac{1}{\frac{1}{Z_1}+s(\frac{1}{Z_2}-\frac{1}{Z_2})}\)
2. 代码实现
校正公式比较难,但代码实现并不复杂。因为光栅化阶段已经进行了透视除法,所以如代码清单 1 所示,我们保存 w 分量。因为计算用的都是 1/w,所以直接保存 w 的倒数。
- struct TVertexShaderOutput
- {
- tmath::Vec4f position;
- bool useColor;
- tmath::Vec4f color;
- bool useUV;
- tmath::Vec2f uv;
- TVertexShaderOutput();
- virtual ~TVertexShaderOutput();
- };
- struct TVertexShaderOutputPrivate : public TVertexShaderOutput
- {
- float invW;
- TVertexShaderOutputPrivate();
- TVertexShaderOutputPrivate Lerp(const TVertexShaderOutputPrivate& other, float t) const;
- };
如代码清单 2 的第 41 至 54 行所示,我们对 w 分量进行记录。同时,我们对颜色和 uv 坐标也先进行除以 w 处理,“减轻”光栅化插值阶段的计算。
- 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);
- TVertexShaderOutputPrivate vertexOutputs[3];
- std::vector<TVertexShaderOutputPrivate> clippedVertices(10);
- 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]);
- }
- clippedVertices.clear();
- switch (mode)
- {
- case TDrawMode::Triangles:
- SutherlandHodgmanClipTriangle(vertexOutputs, clippedVertices);
- break;
- case TDrawMode::Lines:
- default:
- assert(0);
- break;
- }
- if (clippedVertices.empty())
- continue;
- for (TVertexShaderOutputPrivate& vertex : clippedVertices)
- {
- // 透视除法
- vertex.invW = 1 / vertex.position.w();
- vertex.position *= vertex.invW;
- if (vertex.useColor)
- vertex.color *= vertex.invW;
- else
- vertex.uv *= vertex.invW;
- // NDC - 屏幕
- vertex.position = m_screenMatrix * vertex.position;
- }
- switch (mode)
- {
- case TDrawMode::Triangles:
- for (uint32_t k = 1; k + 1 < clippedVertices.size(); k++)
- m_rz.RasterizeTriangle(clippedVertices[0], clippedVertices[k], clippedVertices[k + 1], fragFunc);
- break;
- case TDrawMode::Lines:
- default:
- assert(0);
- break;
- }
- }
- }
光栅化阶段,如代码清单 3 的第 47 行所示,我们先求得 \(\frac{1}{w_o}\)。
因为之前颜色和 uv 坐标已经除以过 w 分量了,所以如第 55 行和第 67 行所示,我们再乘上 \(w_o\),就能得到最终校正后的值。
- void TRasterizer::RasterizeTriangle(
- const TVertexShaderOutputPrivate& v1,
- const TVertexShaderOutputPrivate& v2,
- const TVertexShaderOutputPrivate& 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 interpInvW;
- 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;
- interpInvW = v1.invW * alpha + v2.invW * beta + v3.invW * gamma;
- if (v1.useColor)
- {
- interpolatedInput.color = tmath::interpolate(
- v1.color, alpha,
- v2.color, beta,
- v3.color, gamma
- ) / interpInvW;
- fragShader(interpolatedInput, fragOutput);
- SetPixel(i, j, TRGBA::FromVec4f(fragOutput.color));
- }
- else
- {
- interpolatedInput.uv = tmath::interpolate(
- v1.uv, alpha,
- v2.uv, beta,
- v3.uv, gamma
- ) / interpInvW;
- switch (m_state->GetSampleMode())
- {
- case TSampleMode::Bilinear:
- SetPixel(i, j, SampleTextureBilinear(interpolatedInput.uv));
- break;
- case TSampleMode::Nearest:
- default:
- SetPixel(i, j, SampleTextureNearest(interpolatedInput.uv));
- break;
- }
- }
- }
- }
- }
- }
测试
如代码清单 4 所示,我们写一个棋盘格渲染样例,更容易看清透视校正的结果。我们用两个三角形拼成一个矩形。黑白棋盘纹理是直接用代码生成的。
- #include "TChessboardRenderTask.h"
- TChessboardRenderTask::TChessboardRenderTask(TBasicWindow& win)
- : m_angle(0)
- {
- float vertices[] = {
- -1.0f, -1.0f, 0.0f,
- 1.0f, -1.0f, 0.0f,
- 1.0f, 1.0f, 0.0f,
- -1.0f, 1.0f, 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,
- 0.0f, 0.0f, 0.0f, 1.0f,
- };
- float uvs[] = {
- 0.0f, 0.0f,
- 1.0f, 0.0f,
- 1.0f, 1.0f,
- 0.0f, 1.0f
- };
- uint32_t indices[] = {
- 0, 1, 2,
- 2, 3, 0,
- };
- 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);
- const int textureSize = 8; // 8x8
- std::vector<uint32_t> chessboardTexture(textureSize * textureSize);
- // 生成棋盘格纹理
- for (int y = 0; y < textureSize; ++y)
- {
- for (int x = 0; x < textureSize; ++x)
- {
- bool isBlack = (x + y) % 2 == 0;
- chessboardTexture[y * textureSize + x] = isBlack ? 0xFF000000 : 0xFFFFFFFF; // 黑色或白色
- }
- }
- m_texture = TImage((const unsigned char*)chessboardTexture.data(), 8, 8);
- sr.SetTexture(&m_texture);
- }
- void TChessboardRenderTask::Render(TSoftRenderer& sr)
- {
- Transform();
- sr.Clear({ 0,0,0 });
- sr.DrawElements(TDrawMode::Triangles, 6, 0);
- }
- void TChessboardRenderTask::Transform()
- {
- m_angle -= 0.01f;
- m_shader.modelMatrix = tmath::RotationMatrix(tmath::Vec3f(0.0f, 1.0f, 0.0f), m_angle);
- }
本篇文章的完整代码见 tag/perspective_correction。
最终的运行结果如视频 2 所示,对比文章开头的视频 1 内容,可以看到经过透视校正后,效果显示正常。
References
[1] Low K L. Perspective-correct interpolation[J]. Technical writing, Department of Computer Science, University of North Carolina at Chapel Hill, 2002.