随笔- 2  文章- 0  评论- 1  阅读- 233 

最近在看“红宝书”时发现了其中第165页中关于透视投影矩阵的错误。书中原式如下图所示:

其中 M[0,2]M[1,2] 错误出现了除以 2 的操作(应该是宽度),且 M[2,3] 则漏填了个负号。
接下来我们自己重新推导这个矩阵。

  • 首先需要明确的是,在观察空间中摄像机的取景范围将构成一个视锥体,而透视投影矩阵会将视锥体变换成一个立方体(NDC):可以想象是让视锥体的近平面保持不变,而远平面则缩放到与近平面同等大小,然后映射到 [-1,1] (或者 [0,1] )中。在远平面缩小的这个过程中,在视锥体中的物体也将跟着缩小,从而实现“近大远小”的效果。

  • 其次,坐标系的偏手性非常重要,它关乎到 n,f 的符号和大小判定。本文采用的是:观察空间为右手系(换变前),NDC为左手系(换变后)。

  • 最后,采用行向量还是列向量,以及映射范围的不同也将导致不一样的结果。本文采用列向量的形式,映射范围为 [-1,1].

    1. 投影点的坐标表示

    我们先观察下图:

由相似三角形可以得出,对于一个点 P ,它的投影点 Pxy 坐标的表达式如下:

 x=dzx

(1.1)y=dzy

由于相机望向前方的位置是 z,因此表达式里出现了负号。

2. 视锥体的转换

由于观察空间是右手系,因此看向前方的 z 轴是负值;相机的位置是原点,因此表示近平面的距离记为 n,表示远平面的距离记为 f,它们之间满足 fzn.在近平面中,分为左右上下,分别记为 l,r,t,b,它们分别是围成近平面的边的中点,并且满足 lxr, byt.如下图所示:

它们需要映射到 [1,1] 中,考虑使用斜截式表达这个映射,如下图所示:

在斜截式方程表达式 y=kx+b 中,k=1(1)rl=2rl (2.1)b=(1)2lrl=2lrl1.
因此对于点 P ,把映射后的【它在近平面的投影点的 x 分量】记为 x, 将它应用上面的 y=kx+b 的形式,则有

x=2rlx2lrl1=(xl)2rl1

同理,对于 y 也能得出如下的表达式:

y=(yb)2tb1

如下图所示:

以上两个式子就是将 l,r,t,b 映射到 [1,1] 的线性函数。而由式子(1.1)可以得出

x=nzx

y=nzy

将以上两个变量代入上面 x,y 的线性函数,可得

(2.2)x=2nrl(xz)r+lrl

(2.3)y=2ntb(yz)t+btb

到这一步,视锥体已由锥体变为长方体。接着需要将 z 分量同样映射到 [1,1] 区间,就可以把长方体变为最终齐次除法中的立方体(NDC)。z 分量的计算麻烦一点,因为深度信息的处理涉及到深度插值的处理过程。在大多数的教程中关于 z 分量的推导都是根据与 x,y 的关系一点一点推导出来的,这里采用另外一种思考方法。纵深方向的 n, f 分别的映射关系如下:

n1

f1

从这里就可以看出,这个映射颠倒了变换前(观察空间)z 轴的正指向,由右手系变成了左手系。
接着,考虑一下z 分量的插值需求。通常对于 xy 分量都是比较直接的,但是对于 z 分量有着特殊的需求。

  • 首先,在应用透视投影矩阵后,每个顶点坐标都会被其齐次坐标 w 分量除(齐次除法),对于 z 分量来说,这意味着原始的 z 值会变成 zw 的形式(w 作为分母)。也就是说,wz 成比例。
  • 其次,人类视觉系统对深度的感知是非线性的,我们希望远处的物体变化较小(远离摄像机的地方有较低的精度),而近处的物体变化较大(靠近摄像机的地方有更高的精度)。而我们希望在处理 z 分量时也能使用线性插值以获得正确的深度值。

正因为如此,在光栅化阶段顶点的 z 值将会以 1z 的形式进行插值,随着 z 的增大,1z 的变化速率减小,这正好符合我们对深度精度中模拟人眼感受的需求。因此,应用斜截式方程可以得出以下的表达式:

(2.4)z=Az+B

其中 A,B 是两个待定系数,它们可以联立以下方程组:

{An+B=1Af+B=1

解方程组,可得

A=2nffn

B=f+nfn

将其代入以上的式子(2.4)可得关于 z 的线性映射函数:

(2.5)z=2nffn(1z)+f+nfn

这里把 A1z 取负号是为了和式子(2.2)式子(2.3)统一起来。到这一步,长方体就变换成了NDC。观察式子(2.2),式子(2.3)和式子(2.5),它们均含有同样的分母 z. 所以,点 P 的齐次坐标点为:

P(xz,yz,zz,z)

由式子(2.2),式子(2.3)和式子(2.5),可以得到关于齐次点的表达式如下:

(2.6)xz=2nrlx+r+lrlzyz=2ntby+t+btbzzz=f+nfnz2nffn

3. 提取矩阵因子

现在,可以提取式子(2.6)中关于 x,y,z 的因子了,根据式子的逻辑关系可逐列填出变换矩阵如下:

P=MfrustumP=[2nrl0r+lrl002ntbt+btb000f+nfn2nffn0010][xyz1]

则透视投影矩阵已求。在“红宝书”中,变换前(观察空间)为左手系,z 值随着观察方向是正向累加的,所以 n,f 均为正。代入计算之后 M[2,3] 的位置仍然为负,因此原文此处有误。而 M[0,2]M[1,2] 错误出现了除以 2 的操作则是完全莫须有的错误。

4. GAMES101中关于透视投影矩阵的推导

GAMES101中闫令琪老师引用“虎书”的推导方式:https://www.bilibili.com/video/BV1X7411F744/?spm_id_from=333.788.videopod.episodes&vd_source=57c4e007542d6a99df1a601686225ef6&p=4

其给出的推导方式更直观:首先假设已经存在一个透视投影矩阵 Mpersp ,将它设为未知。接着构造一个透视变换到正交的矩阵 Mpersportho ,这一步是将锥体变换为长方体。最后,将这个变换左乘一个正交矩阵 Mortho ,这一步的目的是将长方体变成正方体,也就是 NDC.表达式为:

Mpersp=MorthoMpersportho

5. 代码实现

代码内容比较简单,因此不注释了。

点击查看代码
public class Vector4
{
    public float X { get; private set; }
    public float Y { get; private set; }
    public float Z { get; private set; }
    public float W { get; private set; }

    public Vector4(float x, float y, float z, float w)
    {
        X = x;
        Y = y;
        Z = z;
        W = w;
    }

    // 矩阵与向量相乘的方法
    public static Vector4 Multiply(float[,] matrix, Vector4 vector)
    {
        float x = matrix[0, 0] * vector.X + matrix[0, 1] * vector.Y + matrix[0, 2] * vector.Z + matrix[0, 3] * vector.W;
        float y = matrix[1, 0] * vector.X + matrix[1, 1] * vector.Y + matrix[1, 2] * vector.Z + matrix[1, 3] * vector.W;
        float z = matrix[2, 0] * vector.X + matrix[2, 1] * vector.Y + matrix[2, 2] * vector.Z + matrix[2, 3] * vector.W;
        float w = matrix[3, 0] * vector.X + matrix[3, 1] * vector.Y + matrix[3, 2] * vector.Z + matrix[3, 3] * vector.W;
        return new Vector4(x, y, z, w);
    }

    public override string ToString()
    {
        return $"({X:F2}, {Y:F2}, {Z:F2}, {W:F2})";
    }
}

public class PerspMatrix
{
    public float L { get; private set; }
    public float R { get; private set; }
    public float T { get; private set; }
    public float B { get; private set; }
    public float N { get; private set; }
    public float F { get; private set; }

    public PerspMatrix(float l, float r, float t, float b, float n, float f)
    {
        if (r == l || t == b || f == n)
            throw new ArgumentException("Invalid frustum parameters: division by zero.");

        L = l;
        R = r;
        T = t;
        B = b;
        N = n;
        F = f;
    }

    public float[,] Frustum()
    {
        float invWidth = 1.0f / (R - L);
        float invHeight = 1.0f / (T - B);
        float invDepth = 1.0f / (F - N);

        float[,] matrix4x4 = new float[4, 4];
        matrix4x4[0, 0] = (float)(2.0 * N * invWidth);
        matrix4x4[0, 1] = 0;
        matrix4x4[0, 2] = (float)((R + L) * invWidth);
        matrix4x4[0, 3] = 0;

        matrix4x4[1, 0] = 0;
        matrix4x4[1, 1] = (float)(2.0 * N * invHeight);
        matrix4x4[1, 2] = (float)((T + B) * invHeight);
        matrix4x4[1, 3] = 0;

        matrix4x4[2, 0] = 0;
        matrix4x4[2, 1] = 0;
        matrix4x4[2, 2] = (float)(-(F + N) * invDepth);
        matrix4x4[2, 3] = (float)(-2.0 * F * N * invDepth);

        matrix4x4[3, 0] = 0;
        matrix4x4[3, 1] = 0;
        matrix4x4[3, 2] = -1;
        matrix4x4[3, 3] = 0;

        return matrix4x4;
    }

    public void PrintMatrix(float[,] matrix)
    {
        Console.WriteLine("Projection Matrix:");
        for (int i = 0; i < 4; i++)
        {
            for (int j = 0; j < 4; j++)
            {
                Console.Write($"{matrix[i, j]:F2}\t");
            }
            Console.WriteLine();
        }
    }

    public Vector4 Apply(Vector4 vector)
    {
        float[,] matrix = Frustum();
        return Vector4.Multiply(matrix, vector);
    }
}
 posted on   小花猫喵喵  阅读(31)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
点击右上角即可分享
微信分享提示