Java软光栅渲染器-透视校正插值

目标

  • 实现纹理透视校正插值(Perspective-Correct 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来做线性插值外,还有使用重心坐标的方法。网上有很多资料,我就不再继续实现了。