Skip to content

第4章:齐次坐标与投影

4.1 齐次坐标

4.1.1 为什么需要齐次坐标

在第3章中,我们遇到了一个问题:平移变换无法用普通的矩阵乘法表示。齐次坐标(Homogeneous Coordinates)的引入,优雅地解决了这个问题。

问题回顾

2D 点 (x, y) 平移 (tx, ty) 后变成 (x+tx, y+ty)

普通矩阵无法表示平移:

┌      ┐   ┌   ┐   ┌        ┐
│ a  b │ × │ x │ = │ ax + by│  ← 无法得到 x + tx
│ c  d │   │ y │   │ cx + dy│
└      ┘   └   ┘   └        ┘


解决方案:增加一个维度

2D 点 (x, y) → 齐次坐标 (x, y, 1)

┌          ┐   ┌   ┐   ┌      ┐
│ 1  0  tx │   │ x │   │ x+tx │
│ 0  1  ty │ × │ y │ = │ y+ty │
│ 0  0  1  │   │ 1 │   │  1   │
└          ┘   └   ┘   └      ┘

现在平移可以用矩阵乘法表示了!

4.1.2 齐次坐标的定义

齐次坐标定义

n 维欧几里得空间中的点,可以用 n+1 维齐次坐标表示:

2D 点 (x, y) → 齐次坐标 (x, y, w),其中 w ≠ 0
3D 点 (x, y, z) → 齐次坐标 (x, y, z, w),其中 w ≠ 0


从齐次坐标转回笛卡尔坐标:

(x, y, w) → (x/w, y/w)
(x, y, z, w) → (x/w, y/w, z/w)


重要性质:
齐次坐标 (kx, ky, kw) 表示的是同一个点(k ≠ 0)

例如:(2, 4, 1)、(4, 8, 2)、(6, 12, 3) 都表示点 (2, 4)


标准化:
通常使用 w = 1 的形式:(x, y, 1) 或 (x, y, z, 1)

4.1.3 齐次坐标的几何意义

几何直观

2D 齐次坐标实际上是在 3D 空间中的一条射线上的点!

                  z (w)


                  │        • (2, 4, 2) ← 射线上的点
                  │       ╱
                  │      ╱
                  │     • (1, 2, 1)  ← w=1 平面上的点
                  │    ╱
                  │   ╱
                  │  • (0.5, 1, 0.5)
                  │ ╱
                  │╱
    ──────────────O──────────────► x
                 ╱│
                ╱ │
               ╱  │
              ╱   │

             y

所有满足 (kx, ky, k) 的点都在同一条射线上,
它们都表示同一个 2D 点 (x, y)。


w = 1 平面:
就是我们平时看到的 2D 平面,所有实际的点都在这个平面上。

4.1.4 点与向量的区别

齐次坐标区分点和向量

点(Position):w = 1
(x, y, 1) 表示位置 (x, y)

向量(Direction):w = 0
(x, y, 0) 表示方向 (x, y)


为什么这样定义?

平移矩阵作用于点:
┌          ┐   ┌   ┐   ┌      ┐
│ 1  0  tx │   │ x │   │ x+tx │
│ 0  1  ty │ × │ y │ = │ y+ty │  ← 点被平移了
│ 0  0  1  │   │ 1 │   │  1   │
└          ┘   └   ┘   └      ┘

平移矩阵作用于向量:
┌          ┐   ┌   ┐   ┌   ┐
│ 1  0  tx │   │ x │   │ x │
│ 0  1  ty │ × │ y │ = │ y │  ← 向量不受平移影响!
│ 0  0  1  │   │ 0 │   │ 0 │
└          ┘   └   ┘   └   ┘

这符合物理直觉:
- 点是位置,平移后位置改变
- 向量是方向,平移不改变方向

4.1.5 代码实现

javascript
class HomogeneousPoint2D {
    constructor(x, y, w = 1) {
        this.x = x;
        this.y = y;
        this.w = w;
    }
    
    // 转换为笛卡尔坐标
    toCartesian() {
        if (this.w === 0) {
            throw new Error('Cannot convert direction to point');
        }
        return {
            x: this.x / this.w,
            y: this.y / this.w
        };
    }
    
    // 规范化(使 w = 1)
    normalize() {
        if (this.w === 0) {
            return this; // 向量保持不变
        }
        return new HomogeneousPoint2D(
            this.x / this.w,
            this.y / this.w,
            1
        );
    }
    
    // 创建点
    static point(x, y) {
        return new HomogeneousPoint2D(x, y, 1);
    }
    
    // 创建向量
    static vector(x, y) {
        return new HomogeneousPoint2D(x, y, 0);
    }
    
    // 是否是点
    isPoint() {
        return this.w !== 0;
    }
    
    // 是否是向量
    isVector() {
        return this.w === 0;
    }
}

4.2 仿射变换

4.2.1 仿射变换的定义

仿射变换(Affine Transformation)是保持直线性质的变换。它包括:线性变换(缩放、旋转、切变)+ 平移。

仿射变换的性质

仿射变换保持:
1. 共线性:变换前在一条直线上的点,变换后仍在一条直线上
2. 平行性:变换前平行的线,变换后仍然平行
3. 比例关系:线段的分割比例保持不变


仿射变换不保持:
1. 角度:角度可能改变
2. 长度:长度可能改变(缩放会改变)
3. 面积:面积可能改变(非等比缩放)


仿射变换示例:

原图形(正方形)        缩放后              旋转后             切变后
    ┌───┐           ┌──────────┐            ◇                ╱╲
    │   │           │          │           ╱╲              ╱  ╲
    │   │    ──►    │          │   ──►    ╱  ╲    ──►    ╱    ╲
    └───┘           └──────────┘          ◇    ◇        ╱──────╲

所有这些变换都是仿射变换

4.2.2 仿射变换矩阵

2D 仿射变换的通用形式

┌            ┐
│ a   b   tx │
│ c   d   ty │   ← 最后一行始终是 [0, 0, 1]
│ 0   0   1  │
└            ┘

其中:
- [a, b; c, d] 是线性变换部分(2×2)
- [tx, ty] 是平移部分

作用于点 (x, y, 1):
x' = ax + by + tx
y' = cx + dy + ty


分解:

┌            ┐   ┌          ┐   ┌        ┐
│ a   b   tx │   │ 1  0  tx │   │ a   b  0│
│ c   d   ty │ = │ 0  1  ty │ × │ c   d  0│
│ 0   0   1  │   │ 0  0  1  │   │ 0   0  1│
└            ┘   └          ┘   └        ┘
                   平移T           线性L

仿射变换 = 平移 × 线性变换

4.2.3 仿射变换的应用

javascript
/**
 * 2D 仿射变换类
 */
class AffineTransform {
    constructor() {
        // [a, c, 0, b, d, 0, tx, ty, 1]
        this.matrix = new Float32Array([
            1, 0, 0,
            0, 1, 0,
            0, 0, 1
        ]);
    }
    
    // 应用变换到点
    transformPoint(x, y) {
        const m = this.matrix;
        return {
            x: m[0] * x + m[3] * y + m[6],
            y: m[1] * x + m[4] * y + m[7]
        };
    }
    
    // 应用变换到向量(忽略平移)
    transformVector(x, y) {
        const m = this.matrix;
        return {
            x: m[0] * x + m[3] * y,
            y: m[1] * x + m[4] * y
        };
    }
    
    // 从 Canvas 的 transform 参数创建
    static fromCanvas(a, b, c, d, e, f) {
        const t = new AffineTransform();
        t.matrix.set([
            a, b, 0,
            c, d, 0,
            e, f, 1
        ]);
        return t;
    }
    
    // 导出为 Canvas 格式
    toCanvasArgs() {
        const m = this.matrix;
        return [m[0], m[1], m[3], m[4], m[6], m[7]];
    }
}

// Canvas 使用示例
const ctx = canvas.getContext('2d');
const transform = new AffineTransform();
// ... 设置变换 ...
ctx.setTransform(...transform.toCanvasArgs());

4.3 正交投影

4.3.1 投影的概念

投影(Projection)是将高维空间中的物体映射到低维空间的过程。在图形学中,最常见的是将 3D 场景投影到 2D 屏幕。

投影的基本概念

                    3D 世界

                      │ 投影

                   2D 屏幕


两种主要的投影类型:

1. 正交投影(Orthographic Projection)
   - 平行光投影
   - 物体大小不随距离变化
   - 用于工程图、CAD

2. 透视投影(Perspective Projection)
   - 模拟人眼/相机
   - 近大远小
   - 用于游戏、影视

4.3.2 正交投影的原理

正交投影

正交投影简单地丢弃 z 坐标:

3D 点 (x, y, z) → 2D 点 (x, y)


正交投影矩阵(简单版):

┌            ┐
│ 1  0  0  0 │
│ 0  1  0  0 │
│ 0  0  0  0 │   ← z 被丢弃
│ 0  0  0  1 │
└            ┘


视觉效果:

        正交投影

    ┌─────┼─────┐
    │     │     │
    │   ┌─┼─┐   │
    │   │ │ │   │
    │   └─┼─┘   │
    │     │     │
    └─────┼─────┘

        结果:
    ┌───────────┐
    │           │
    │   ┌───┐   │
    │   │   │   │
    │   └───┘   │
    │           │
    └───────────┘

近处和远处的物体大小相同

4.3.3 标准正交投影矩阵

实际应用中,需要将一个 3D 包围盒映射到标准化设备坐标(NDC):

正交投影矩阵

将 [left, right] × [bottom, top] × [near, far] 映射到 [-1, 1]³

┌                                        ┐
│   2/(r-l)      0          0       tx   │
│     0       2/(t-b)       0       ty   │
│     0          0       -2/(f-n)   tz   │
│     0          0          0        1   │
└                                        ┘

其中:
tx = -(r+l)/(r-l)
ty = -(t+b)/(t-b)
tz = -(f+n)/(f-n)


简化版本(对称情况):

如果 left = -right, bottom = -top:

┌                           ┐
│ 1/right    0       0    0 │
│    0    1/top      0    0 │
│    0       0   -2/(f-n) tz│
│    0       0       0    1 │
└                           ┘

4.3.4 代码实现

javascript
class Matrix4 {
    // ... 前面的代码 ...
    
    /**
     * 创建正交投影矩阵
     * @param left 左边界
     * @param right 右边界
     * @param bottom 下边界
     * @param top 上边界
     * @param near 近平面
     * @param far 远平面
     */
    static orthographic(left, right, bottom, top, near, far) {
        const m = new Matrix4();
        const e = m.elements;
        
        const rl = right - left;
        const tb = top - bottom;
        const fn = far - near;
        
        e[0] = 2 / rl;
        e[5] = 2 / tb;
        e[10] = -2 / fn;
        
        e[12] = -(right + left) / rl;
        e[13] = -(top + bottom) / tb;
        e[14] = -(far + near) / fn;
        
        return m;
    }
    
    /**
     * 简化的正交投影(对称)
     */
    static orthoSymmetric(width, height, near, far) {
        return Matrix4.orthographic(
            -width / 2, width / 2,
            -height / 2, height / 2,
            near, far
        );
    }
}

// 使用示例
const ortho = Matrix4.orthographic(-10, 10, -10, 10, 0.1, 100);

4.4 透视投影

4.4.1 透视投影的原理

透视投影的直觉

透视投影模拟人眼或相机的成像原理:近大远小。

        眼睛/相机

           ╱│╲
          ╱ │ ╲
         ╱  │  ╲
        ╱   │   ╲
       ╱    │    ╲
      ╱     │     ╲
     ╱      │      ╲
    ┌───────┼───────┐  ← 近平面(屏幕)
    │  近   │  物   │
    │  处   │  体   │
    └───────┼───────┘

    ┌───────────────────┐
    │     远处物体      │  ← 投影后变小
    └───────────────────┘


数学关系:

设相机在原点,看向 -z 方向,
点 P(x, y, z) 投影到近平面 z = -n:

            相机
              O
             ╱│
            ╱ │
           ╱  │ n(近平面距离)
          ╱   │
         P────┼────────  z = -n 平面
         │    │
         │    │ z(物体深度)
         │    │
         ●────┴─  实际物体位置


相似三角形:
x'/n = x/|z|  →  x' = nx/|z| = -nx/z
y'/n = y/|z|  →  y' = ny/|z| = -ny/z

4.4.2 透视除法

透视投影的关键:透视除法

透视投影需要除以 z,但矩阵乘法不能直接做除法。

解决方案:利用齐次坐标!

┌                ┐   ┌   ┐   ┌     ┐
│ n  0  0   0    │   │ x │   │ nx  │
│ 0  n  0   0    │ × │ y │ = │ ny  │
│ 0  0  A   B    │   │ z │   │ Az+B│
│ 0  0  -1  0    │   │ 1 │   │ -z  │
└                ┘   └   ┘   └     ┘

得到齐次坐标 (nx, ny, Az+B, -z)

转换为笛卡尔坐标(除以 w = -z):
x' = nx/(-z) = -nx/z
y' = ny/(-z) = -ny/z
z' = (Az+B)/(-z) = -A - B/z


透视除法发生在:
齐次坐标 → 笛卡尔坐标 的转换过程中
这是由 GPU 硬件自动完成的!

4.4.3 透视投影矩阵

标准透视投影矩阵

将视锥体映射到 NDC [-1, 1]³

┌                                   ┐
│ 2n/(r-l)     0      (r+l)/(r-l)  0│
│    0     2n/(t-b)   (t+b)/(t-b)  0│
│    0         0     -(f+n)/(f-n) -2fn/(f-n)│
│    0         0          -1       0│
└                                   ┘


使用视场角(FOV)的版本:

┌                                   ┐
│ 1/(a·tan(fov/2))   0        0     0│
│       0       1/tan(fov/2)  0     0│
│       0            0        A     B│
│       0            0       -1     0│
└                                   ┘

其中:
a = aspect ratio(宽高比)= width/height
fov = 垂直视场角
A = -(f+n)/(f-n)
B = -2fn/(f-n)

4.4.4 代码实现

javascript
class Matrix4 {
    // ... 前面的代码 ...
    
    /**
     * 创建透视投影矩阵
     * @param fov 垂直视场角(弧度)
     * @param aspect 宽高比
     * @param near 近平面
     * @param far 远平面
     */
    static perspective(fov, aspect, near, far) {
        const m = new Matrix4();
        const e = m.elements;
        
        const f = 1 / Math.tan(fov / 2);
        const nf = 1 / (near - far);
        
        e[0] = f / aspect;
        e[5] = f;
        e[10] = (far + near) * nf;
        e[11] = -1;
        e[14] = 2 * far * near * nf;
        e[15] = 0;
        
        return m;
    }
    
    /**
     * 透视投影(指定边界)
     */
    static frustum(left, right, bottom, top, near, far) {
        const m = new Matrix4();
        const e = m.elements;
        
        const rl = right - left;
        const tb = top - bottom;
        const fn = far - near;
        
        e[0] = 2 * near / rl;
        e[5] = 2 * near / tb;
        e[8] = (right + left) / rl;
        e[9] = (top + bottom) / tb;
        e[10] = -(far + near) / fn;
        e[11] = -1;
        e[14] = -2 * far * near / fn;
        e[15] = 0;
        
        return m;
    }
}

// 使用示例
const fov = 60 * Math.PI / 180; // 60 度
const aspect = canvas.width / canvas.height;
const projection = Matrix4.perspective(fov, aspect, 0.1, 1000);

4.5 视锥体

4.5.1 视锥体的概念

视锥体(View Frustum)

视锥体是相机能够"看到"的空间区域。

透视投影的视锥体:                 正交投影的视锥体:

      相机                              相机
        •                                │
       ╱│╲                               │
      ╱ │ ╲                         ┌────┼────┐
     ╱  │  ╲                        │    │    │
    ╱   │   ╲  ← 远平面             │    │    │ ← 远平面
   ╱    │    ╲                      │    │    │
  ╱─────┼─────╲ ← 近平面            ├────┼────┤ ← 近平面
 ╱      │      ╲                    │    │    │
╱       │       ╲                   └────┴────┘

视锥体是一个截头锥体               视锥体是一个长方体


视锥体的 6 个平面:
1. 近平面(Near)
2. 远平面(Far)
3. 左平面(Left)
4. 右平面(Right)
5. 上平面(Top)
6. 下平面(Bottom)

4.5.2 视锥体剔除

视锥体剔除(Frustum Culling)

只渲染视锥体内的物体,提高性能。

              视锥体
         ╱───────────╲
        ╱             ╲
       ╱    ○ ←渲染    ╲
      ╱       ┌─┐       ╲
     ╱        │ │        ╲
    ╱─────────┴─┴─────────╲
   ╱                       ╲
  ╱    ×              ×     ╲   ← 这两个物体在视锥体外,不渲染
 ╱   ┌─┐            ┌─┐      ╲
╱    └─┘            └─┘       ╲


判断步骤:
1. 获取物体的包围盒(AABB 或 OBB)
2. 检查包围盒与视锥体 6 个平面的关系
3. 如果完全在任一平面外侧,则剔除

4.5.3 代码实现

javascript
/**
 * 视锥体类
 */
class Frustum {
    constructor() {
        // 6 个平面,每个平面用 (a, b, c, d) 表示:ax + by + cz + d = 0
        this.planes = [
            { a: 0, b: 0, c: 0, d: 0 }, // near
            { a: 0, b: 0, c: 0, d: 0 }, // far
            { a: 0, b: 0, c: 0, d: 0 }, // left
            { a: 0, b: 0, c: 0, d: 0 }, // right
            { a: 0, b: 0, c: 0, d: 0 }, // top
            { a: 0, b: 0, c: 0, d: 0 }  // bottom
        ];
    }
    
    /**
     * 从投影矩阵和视图矩阵提取视锥体平面
     */
    setFromMatrix(viewProjection) {
        const m = viewProjection.elements;
        
        // 左平面
        this.planes[2].a = m[3] + m[0];
        this.planes[2].b = m[7] + m[4];
        this.planes[2].c = m[11] + m[8];
        this.planes[2].d = m[15] + m[12];
        
        // 右平面
        this.planes[3].a = m[3] - m[0];
        this.planes[3].b = m[7] - m[4];
        this.planes[3].c = m[11] - m[8];
        this.planes[3].d = m[15] - m[12];
        
        // 底平面
        this.planes[5].a = m[3] + m[1];
        this.planes[5].b = m[7] + m[5];
        this.planes[5].c = m[11] + m[9];
        this.planes[5].d = m[15] + m[13];
        
        // 顶平面
        this.planes[4].a = m[3] - m[1];
        this.planes[4].b = m[7] - m[5];
        this.planes[4].c = m[11] - m[9];
        this.planes[4].d = m[15] - m[13];
        
        // 近平面
        this.planes[0].a = m[3] + m[2];
        this.planes[0].b = m[7] + m[6];
        this.planes[0].c = m[11] + m[10];
        this.planes[0].d = m[15] + m[14];
        
        // 远平面
        this.planes[1].a = m[3] - m[2];
        this.planes[1].b = m[7] - m[6];
        this.planes[1].c = m[11] - m[10];
        this.planes[1].d = m[15] - m[14];
        
        // 归一化所有平面
        for (const plane of this.planes) {
            const len = Math.sqrt(
                plane.a * plane.a + 
                plane.b * plane.b + 
                plane.c * plane.c
            );
            plane.a /= len;
            plane.b /= len;
            plane.c /= len;
            plane.d /= len;
        }
    }
    
    /**
     * 检查点是否在视锥体内
     */
    containsPoint(x, y, z) {
        for (const plane of this.planes) {
            const dist = plane.a * x + plane.b * y + plane.c * z + plane.d;
            if (dist < 0) return false;
        }
        return true;
    }
    
    /**
     * 检查球体是否与视锥体相交
     */
    intersectsSphere(x, y, z, radius) {
        for (const plane of this.planes) {
            const dist = plane.a * x + plane.b * y + plane.c * z + plane.d;
            if (dist < -radius) return false;
        }
        return true;
    }
    
    /**
     * 检查 AABB 是否与视锥体相交
     */
    intersectsAABB(minX, minY, minZ, maxX, maxY, maxZ) {
        for (const plane of this.planes) {
            // 找到最靠近平面的顶点
            const px = plane.a > 0 ? maxX : minX;
            const py = plane.b > 0 ? maxY : minY;
            const pz = plane.c > 0 ? maxZ : minZ;
            
            const dist = plane.a * px + plane.b * py + plane.c * pz + plane.d;
            if (dist < 0) return false;
        }
        return true;
    }
}

4.6 视口变换

4.6.1 坐标空间总览

3D 渲染的坐标变换流程

局部空间 ──► 世界空间 ──► 相机空间 ──► 裁剪空间 ──► NDC ──► 屏幕空间
        Model      View       Projection   透视除法   Viewport


1. 局部空间(Local/Object Space)
   - 物体自身的坐标系
   - 以物体中心为原点

2. 世界空间(World Space)
   - 整个场景的坐标系
   - Model 矩阵:放置物体到世界中

3. 相机空间(View/Camera/Eye Space)
   - 以相机为原点
   - View 矩阵:相机的逆变换

4. 裁剪空间(Clip Space)
   - 齐次坐标空间
   - Projection 矩阵:应用投影

5. 标准化设备坐标(NDC)
   - [-1, 1]³ 的立方体
   - 透视除法:w 分量除法

6. 屏幕空间(Screen Space)
   - 像素坐标
   - Viewport 变换:映射到屏幕

4.6.2 视口变换矩阵

视口变换

将 NDC [-1, 1] × [-1, 1] 映射到屏幕 [x, x+w] × [y, y+h]


变换公式:
x_screen = (x_ndc + 1) × (width / 2) + x
y_screen = (1 - y_ndc) × (height / 2) + y   ← 注意:y 轴翻转

或者(如果 y 不翻转):
y_screen = (y_ndc + 1) × (height / 2) + y


深度映射:
z_screen = (z_ndc + 1) × (depthRange / 2) + depthNear

通常 depthRange = 1, depthNear = 0
z_screen = (z_ndc + 1) / 2,范围 [0, 1]


视口矩阵:

┌                          ┐
│ w/2    0    0   x + w/2  │
│  0    h/2   0   y + h/2  │   ← 如果 y 不翻转
│  0     0   d/2  d/2      │
│  0     0    0      1     │
└                          ┘

其中 d = depthRange

4.6.3 完整的 MVP 变换

javascript
/**
 * 相机类
 */
class Camera {
    constructor() {
        this.position = { x: 0, y: 0, z: 5 };
        this.target = { x: 0, y: 0, z: 0 };
        this.up = { x: 0, y: 1, z: 0 };
        
        this.fov = 60 * Math.PI / 180;
        this.aspect = 1;
        this.near = 0.1;
        this.far = 1000;
        
        this.viewMatrix = new Matrix4();
        this.projectionMatrix = new Matrix4();
        this.viewProjectionMatrix = new Matrix4();
    }
    
    /**
     * 创建 lookAt 视图矩阵
     */
    updateViewMatrix() {
        const m = this.viewMatrix;
        const e = m.elements;
        
        // 计算相机坐标系
        const zAxis = normalize({
            x: this.position.x - this.target.x,
            y: this.position.y - this.target.y,
            z: this.position.z - this.target.z
        });
        
        const xAxis = normalize(cross(this.up, zAxis));
        const yAxis = cross(zAxis, xAxis);
        
        // 构建视图矩阵
        e[0] = xAxis.x;  e[4] = xAxis.y;  e[8]  = xAxis.z;
        e[1] = yAxis.x;  e[5] = yAxis.y;  e[9]  = yAxis.z;
        e[2] = zAxis.x;  e[6] = zAxis.y;  e[10] = zAxis.z;
        
        e[12] = -dot(xAxis, this.position);
        e[13] = -dot(yAxis, this.position);
        e[14] = -dot(zAxis, this.position);
        
        return this;
    }
    
    /**
     * 更新投影矩阵
     */
    updateProjectionMatrix() {
        this.projectionMatrix = Matrix4.perspective(
            this.fov, this.aspect, this.near, this.far
        );
        return this;
    }
    
    /**
     * 更新视图投影矩阵
     */
    updateViewProjectionMatrix() {
        this.viewProjectionMatrix = this.projectionMatrix.multiply(
            this.viewMatrix
        );
        return this;
    }
    
    /**
     * 世界坐标转屏幕坐标
     */
    worldToScreen(point, viewportWidth, viewportHeight) {
        // 应用 VP 矩阵
        const clip = this.viewProjectionMatrix.transformPoint(
            point.x, point.y, point.z
        );
        
        // 透视除法已在 transformPoint 中完成
        // 转换到屏幕坐标
        return {
            x: (clip.x + 1) * viewportWidth / 2,
            y: (1 - clip.y) * viewportHeight / 2, // y 翻转
            z: (clip.z + 1) / 2 // 深度 [0, 1]
        };
    }
    
    /**
     * 屏幕坐标转射线(用于拾取)
     */
    screenToRay(screenX, screenY, viewportWidth, viewportHeight) {
        // 屏幕坐标转 NDC
        const ndcX = (screenX / viewportWidth) * 2 - 1;
        const ndcY = 1 - (screenY / viewportHeight) * 2;
        
        // 获取 VP 矩阵的逆
        const invVP = this.viewProjectionMatrix.inverse();
        
        // 近平面和远平面上的点
        const nearPoint = invVP.transformPoint(ndcX, ndcY, -1);
        const farPoint = invVP.transformPoint(ndcX, ndcY, 1);
        
        // 射线方向
        const direction = normalize({
            x: farPoint.x - nearPoint.x,
            y: farPoint.y - nearPoint.y,
            z: farPoint.z - nearPoint.z
        });
        
        return {
            origin: this.position,
            direction: direction
        };
    }
}

// 辅助函数
function dot(a, b) {
    return a.x * b.x + a.y * b.y + a.z * b.z;
}

function cross(a, b) {
    return {
        x: a.y * b.z - a.z * b.y,
        y: a.z * b.x - a.x * b.z,
        z: a.x * b.y - a.y * b.x
    };
}

function normalize(v) {
    const len = Math.sqrt(v.x * v.x + v.y * v.y + v.z * v.z);
    return { x: v.x / len, y: v.y / len, z: v.z / len };
}

4.7 本章小结

核心概念

概念说明用途
齐次坐标(x, y, w) 表示 2D 点统一处理平移和线性变换
仿射变换保持直线和平行的变换2D/3D 图形变换
正交投影平行投影,无透视CAD、2D 游戏
透视投影近大远小3D 游戏、影视
视锥体相机可见区域剔除优化
视口变换NDC 到屏幕坐标最终显示

坐标空间变换链

物体坐标 ─[Model]─► 世界坐标 ─[View]─► 相机坐标 ─[Projection]─► 裁剪坐标

                                                              [透视除法]


                              屏幕坐标 ◄─[Viewport]─ NDC坐标

关键矩阵

正交投影:                          透视投影:
┌                    ┐              ┌                    ┐
│ 2/(r-l)  0    0  tx│              │ f/a   0    0    0  │
│   0   2/(t-b) 0  ty│              │  0    f    0    0  │
│   0     0  -2/(f-n) tz│           │  0    0    A    B  │
│   0     0    0    1 │             │  0    0   -1    0  │
└                    ┘              └                    ┘

其中 f = 1/tan(fov/2), A = -(f+n)/(f-n), B = -2fn/(f-n)

下一章预告:在第5章中,我们将学习经典的 2D 图形绘制算法,包括直线、圆、多边形的绘制。


文档版本:v1.0
字数统计:约 10,000 字

如有转载或 CV 的请标注本站原文地址