现在我们的管线流程还存在一些问题。之前的三角形看着不直观,我们看到视频 1 中的棋盘格显示,可以发现效果是错误的。
视频 1 未透视校正
错误的原因如图 1 所示 [1] ,比如我们在透视投影之后得到的插值比例是 0.5,但是透视投影之前对应的插值点,比例不是 0.5。透视投影之后得到的插值是错误的,我们希望把它修正成透视投影之前正确的插值。
图1 错误原因
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 的倒数。
代码清单 1 记录 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 处理,“减轻”光栅化插值阶段的计算。
代码清单 2 记录
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\),就能得到最终校正后的值。
代码清单 3 透视校正
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 所示,我们写一个棋盘格渲染样例,更容易看清透视校正的结果。我们用两个三角形拼成一个矩形。黑白棋盘纹理是直接用代码生成的。
代码清单 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 内容,可以看到经过透视校正后,效果显示正常。
视频 2 未透视校正
References
[1] Low K L. Perspective-correct interpolation[J]. Technical writing, Department of Computer Science, University of North Carolina at Chapel Hill, 2002.