目标
- 实现纹理透视校正插值(Perspective-Correct Interpolation)
校正前
校正后
参考资料:
- 《3D游戏编程大师技巧》12.5 透视修正纹理映射和1/z缓存
- 3D 图形光栅化的透视校正问题?
- 【GPU技术】透视校正插值
- 透视校正插值
- 透视校正插值
- 纹理坐标三角形内插值问题,我搞不定了。?
- 顶点属性插值
- 重心座标插值(Barycentric Interpolation)
- 重心坐标
实现
原理
在投影平面上,顶点的x、y坐标可以线性插值。
x = (1-t)*x0+t*x1
y = (1-t)*y0+t*y1
z坐标并不是线性变化的,但1/z满足线性变化。
1/z = (1-t)*(1/z0)+t*(1/z1)
通过上面的公式可以推导出z坐标的值。
z = 1/[(1-t)/z0+t/z1]
得到正确的z值后,就可以计算出纹理坐标在透视空间中实际的插值系数。
s = (z-z0)/(z1-z0)
使用s对uv进行线性插值,就可以得到平面上(x,y)点实际对应的uv坐标,实现透视校正插值。
uv = (1-s)*uv0+s*uv1
但是这里有个问题。
在进行光栅化之前,所有顶点都被MVP矩阵转换成了投影空间中的齐次坐标,并且通过透视除法,将(x, y, z, w)四个分量除以w,得到(x/w, y/w, z/w, 1)坐标。
这时候,所有顶点都变换到了齐次空间(或者叫做cvv,规范立方体),x、y、z的取值范围都是[-1, 1]。如果用这个z坐标来计算的话,可能会有很大的误差。最好使用透视除法发生之前的z坐标来进行计算。
一种靠谱的做法,是在透视除法发生之前,记录一下投影空间中的z坐标值,这样在透视校正的时候就可以使用这个值来进行计算了。
又因为此时 z 和 w 是成正比的,也可以干脆直接保留 w 的值来计算透视校正插值系数 s。
w = 1/[(1-t)/w0+t/w1]
s = (w-w0)/(w1-w0)
别问我为什么,去看上面的参考资料,我直接用结论。
保存w的值
在透视除法发生前,要保存w的值。可以在RasterizationVertex中定义一个w值,然后在perspectiveDivide方法中给它赋值。
public class RasterizationVertex {
public Vector4f position = new Vector4f(); // 片段位置
public Vector4f color = new Vector4f(1); // 片段颜色
public Vector3f normal = new Vector3f(); // 片段法线
public Vector2f texCoord = new Vector2f(); // 纹理坐标
public float w;// 透视除法之前的w坐标
/**
* 透视除法
*/
public void perspectiveDivide() {
// 保存w值,用于透视修正
w = position.w;
// 齐次坐标
position.multLocal(1f / w);
}
}
事实上,这里并不需要单独定义一个w成员。完全可以只修改position中x、y、z的值,然后保持w值不变就好。不过为了下面写代码方便,我就按现在的方式做了,毕竟 v0.w
比
v0.position.w
要短那么一点。
计算透视校正插值系数
在RasterizationVertex类中增加一个方法,用于计算透视校正插值系数。
/**
* 计算透视修正插值系数
* @param v0
* @param v1
* @param t
* @return
*/
private float perspectiveCorrect(RasterizationVertex v0,
RasterizationVertex v1, float t) {
// 使用 1/w0 和 1/w1 进行线性插值,计算出 1/w。
float oneOverW0 = 1f / v0.w;
float oneOverW1 = 1f / v1.w;
float oneOverW = (1 - t) * oneOverW0 + t * oneOverW1;
// 使用 s = (w-w0)/(w1-w0),计算透视修正插值系数。
w = 1f / oneOverW;
return (w - v0.w) / (v1.w - v0.w);
}
纹理透视校正插值
修改 RasterizationVertex 中的 interpolateLocal 方法,先计算透视校正插值系数 s,然后用 s 来对纹理坐标进行线性插值。
/**
* 插值
* @param
* @param v1
* @param t
* @return
*/
public RasterizationVertex interpolateLocal(RasterizationVertex v0,
RasterizationVertex v1, float t) {
// 顶点插值
position.interpolateLocal(v0.position, v1.position, t);
// 法线插值
if (v0.hasNormal) {
normal.interpolateLocal(v0.normal, v1.normal, t);
this.hasNormal = v0.hasNormal;
}
// 颜色插值
if (v0.hasVertexColor) {
color.interpolateLocal(v0.color, v1.color, t);
this.hasVertexColor = v0.hasVertexColor;
}
// 纹理插值
if (v0.hasTexCoord) {
///// 透视修正插值 /////
float s = perspectiveCorrect(v0, v1, t);
texCoord.interpolateLocal(v0.texCoord, v1.texCoord, s);
this.hasTexCoord = v0.hasTexCoord;
}
return this;
}
事实上,还可以用 s 对 color 和 normal 进行修正。不过因为差别不是很明显,所以这里就不处理了。
测试结果
执行我们以前写的测试用例,看看现在纹理是否正常了。
前三个都蛮正常的,不过第四个是什么情况!?
透视校正功能本身应该是没问题的,第四个图的情况需要进一步调试分析,等以后再解决吧。
改进代码
根据3D 图形光栅化的透视校正问题? - 韦易笑的回答 - 知乎,透视修正插值在代码中还有更好的实现方法。
即把顶点数据:
(x, y, z, w) + (u, v)
变换成:
(x / w, y / w, z / w, 1 / w) + (u / w, v / w)
然后用 1/w, u/w, v/w进行屏幕空间插值,具体绘制某个点的时候,先从1/w求倒得到w,然后乘以 u/w, v/w得到 u, v,就可以了。
改写perspectiveDivide
删掉 RasterizationVertex 中的 w 成员以及 perspectiveCorrect 方法。
修改 perspectiveDivide 方法,把纹理坐标也除以 w,同时用position.w 记录 1 / w 的值。
实际上,还可以对normal、color都除以w,效果是一样的。
/**
* 透视除法
*/
public void perspectiveDivide() {
float oneOverW = 1f / position.w;
// 透视除法
position.multLocal(oneOverW);
texCoord.multLocal(oneOverW);
// 记录1 / w
position.w = oneOverW;
}
恢复线性插值
顶点坐标中的 w 变成了 1/w,纹理坐标的 (u, v) 变成了
(u/w, v/w),可以直接线性插值了。删除 interpolateLocal 中调用perspectiveCorrect的代码,让所有数据线性插值。
/**
* 插值
* @param
* @param v1
* @param t
* @return
*/
public RasterizationVertex interpolateLocal(RasterizationVertex v0,
RasterizationVertex v1, float t) {
// 顶点插值
position.interpolateLocal(v0.position, v1.position, t);
// 法线插值
if (v0.hasNormal) {
normal.interpolateLocal(v0.normal, v1.normal, t);
this.hasNormal = v0.hasNormal;
}
// 颜色插值
if (v0.hasVertexColor) {
color.interpolateLocal(v0.color, v1.color, t);
this.hasVertexColor = v0.hasVertexColor;
}
// 纹理插值
if (v0.hasTexCoord) {
texCoord.interpolateLocal(v0.texCoord, v1.texCoord, t);
this.hasTexCoord = v0.hasTexCoord;
}
return this;
}
修正像素着色阶段
重点来了。
在执行fragmentShader之前,把纹理坐标乘以 w,从 (u/w, v/w) 恢复成原来的 (u, v)。
public void rasterizePixel(int x, int y, RasterizationVertex frag) {
if (x < 0 || y < 0 || x >= width || y >= height) {
return;
}
// 执行片段着色器
frag.texCoord.multLocal(1f / frag.position.w);// 透视修正
fragmentShader(frag);
int index = x + y * width;
float depth = frag.position.z;
// ..
}
存在的问题
在光栅化的最初阶段,我们做了两件事:
执行透视除法:把顶点从投影空间变换到CVV空间,这样就可以用x、y的值来渲染投影平面上的顶点、线段、三角形。
屏幕空间变换:把x、y坐标从[-1, 1]变换到[0, width]和[0, height],其中width和height分别是屏幕的宽和高。也叫视口变换。
代码是这样的:
/**
* 光栅化三角形
* @param v0
* @param v1
* @param v2
*/
public void rasterizeTriangle(RasterizationVertex v0, RasterizationVertex v1, RasterizationVertex v2) {
// 将顶点变换到投影平面
v0.perspectiveDivide();
v1.perspectiveDivide();
v2.perspectiveDivide();
Matrix4f viewportMatrix = renderer.getViewportMatrix();
// 把顶点位置修正到屏幕空间。
viewportMatrix.mult(v0.position, v0.position);
viewportMatrix.mult(v1.position, v1.position);
viewportMatrix.mult(v2.position, v2.position);
// ...
}
在改写了perspectiveDivide()方法后,第二步的屏幕空间变换发生了一点问题。
原因分析
对于宽为width,高为height的屏幕,屏幕空间变换矩阵是这样的:
|width/2 0 0 width/2 |
| 0 -height/2 0 height/2 |
| 0 0 1 0 |
| 0 0 0 1 |
原本预计进过透视除法后,齐次坐标能够变成这样一种形式:
(x, y, z, w) 变成 (x', y', z', 1)
其中 x'=x/w,y'=y/w,z'=z/w。
原本 viewportMatrix * (x', y', z', 1)的结果是:
(x' * width/2 + width / 2, - y' * height / 2 + height / 2, z', 1)
此时,这个矩阵其实等效于两个公式:
x = x * width/2 + width/2;
y = -y * height/2 + height/2;
或者
f(x, y) = (x, y) * (width/2, -height/2) + (width/2, height/2)
(width/2, height/2)是屏幕的中心点。当x'和y'的取值范围为[-1, 1]时,这个变换的效果就成了先把顶点的原点平移到屏幕中心,然后让x和y的取值在[-width/2, width/2]和[-height/2, height/2]范围内变化。
本来这个矩阵是很完美的,然而现在 w 的值从 1 变成了 1 / w。
viewportMatrix * (x', y', z', 1/w)的结果是:
(x' * width/2 + width / 2 / w, - y' * height / 2 + height / 2 / w, z', 1 / w)
让我们忽略掉 z 、w这两个分量,专门考察x、y的值。用一个公式来描述这个变换。
f(x, y) = (x, y) * (width/2, -height/2) + (width/2/w, height/2/w)
x 和 y 的依然在 [-width/2, width/2] 和 [-height/2, height/2] 之间变化,但是现在投影空间的原点不在屏幕中心了!原点被平移到了:
(width/2/w, height/2/w)
由于 w 通常是个大于1的正数,这意味着原点坐标被按比例收缩到了屏幕左上角。
解决方法
这个问题有两种解决思路。
第一种,放弃viewportMatrix矩阵,改成直接用下面这个公式来计算x、y在屏幕空间的值。
x = x * width/2 + width/2;
y = -y * height/2 + height/2;
这种方法的麻烦之处在于需要对每个顶点都计算一次,而且视口被固定在了整个屏幕。
第二种方法很简单,把透视除法和屏幕空间变换换个顺序就行。(如果你不明白,可以先算一下 viewportMatrix * (x, y, z, w)的结果,然后再执行透视除法。)
我选择第二种方式,因为将来还打算试试不同的视口变换矩阵。
实现代码是这样的:
/**
* 光栅化三角形
* @param v0
* @param v1
* @param v2
*/
public void rasterizeTriangle(RasterizationVertex v0, RasterizationVertex v1, RasterizationVertex v2) {
Matrix4f viewportMatrix = renderer.getViewportMatrix();
// 把顶点位置修正到屏幕空间。
viewportMatrix.mult(v0.position, v0.position);
viewportMatrix.mult(v1.position, v1.position);
viewportMatrix.mult(v2.position, v2.position);
// 将顶点变换到投影平面
v0.perspectiveDivide();
v1.perspectiveDivide();
v2.perspectiveDivide();
// ...
}
修复后的效果如下:
连前面出现的bug也修复了,看来原因是由w的值影响了屏幕空间变换导致的。
总结
目标达成。
透视校正插值除了使用1/w来做线性插值外,还有使用重心坐标的方法。网上有很多资料,我就不再继续实现了。