第 32 章 动手一:亲手把立方体”画”到屏幕上(坐标变换可运行版)

往前翻几章(第 27 章)我们走完了坐标变换的六道关:Model → View → Projection → 裁剪 → 透视除法 → Viewport。我当时跟你说过,那条链是”能手写 shader、能排查穿模”的钥匙。你当时读得很顺,点头如捣蒜,MVP、裁剪、透视除法一套一套的。

可我现在要问你一个有点冒犯的问题:

如果现在让你合上书、不查任何资料,从零写一个程序,把第 2 章那个立方体的 8 个顶点,真真切切地算到屏幕的像素坐标上——你能写出来吗?

我猜,大概率卡住。不是你笨,是因为”看懂别人的推导”和”自己能写出来”之间,隔着的不是一层纸,是一万行代码的距离。你是个写 Go 的后端,变量、数组、函数、循环你门儿清,但图形学底层对你来说是新领域。你”懂”了第 27 章的概念,但那些概念还没变成你手指下可执行的指令。

这就像你看别人下棋解说,觉得”这步妙啊、那步精啊”,自己上手却被将军。看懂棋谱,不等于会下棋。第 27 章是解说,这一章,是你自己上手下棋。

所以这一章我们不讲任何新理论。我把第 27 章的六步,原封不动地翻译成纯 Python——只用标准库里的 math,连 matplotlib 都是可选的(没装也能跑,我用 ASCII 图给你画出来)。你的任务只有一件:亲手把脚本跑一遍、改几个数再跑一遍,亲眼看见一个三维点怎么一步步”飞”到屏幕像素上。

你带走的不该是”我好像看懂了”,而是”我亲手算出来了”。这两件事,差着一万行代码。咱们今天就把这距离填上一截。

32.1 为什么必须动手:从”看懂”到”会算”差着什么

我们先把”假懂”这件事说透,因为它太常见了。

你回想一下第 27 章。我当时给你推了一个顶点:局部 (1,1,1) 经过 Model 平移到世界 (11,1,1),再经过 View 减掉相机位置变成 (11,1,-4),投影后 w 设成 4,透视除法一除变成 NDC,最后 Viewport 映射到像素。你读的时候是不是觉得”嗯,通了,原来这么简单”?

然后我让你写——你发现,第一个坎就来了:矩阵在代码里到底怎么存?是用一个二维列表、还是四个列向量?mat · vec 这个乘法,到底是该 sum(m[i][j] * v[j]) 还是 sum(m[j][i] * v[j])?你脑子里那个漂亮的矩阵公式,落到键盘上就成了”这下标到底怎么排”。

这就是”假懂”的裂缝:你知道结果长什么样,但不知道从哪一行代码开始敲。

更隐蔽的裂缝在 w。第 27 章我说”投影后 w 不再是 1,透视除法用 w 制造近大远小”。你点头。可是你真跑一遍会发现:为什么我这个顶点投影后 w 等于 8?为什么是 8 不是 7 也不是 1?这个 8 是哪来的?只有当你亲手算出 w = -z_cam = 8,你才真正信了”w 是深度的负数”——而不是把它当一句需要背诵的咒语。

所以这一章的核心主张是:图形学里最容易假懂的就是坐标变换,而唯一能戳破假懂的,就是亲手算出一组具体数字。 你不需要记住公式(公式随时能查),但你必须亲手验证过一次:NDC 真的落在 [-1, 1] 吗?w 真的随深度变吗?近的真的比远的大吗?这些数据从你手指底下跑出来,你才算真会。

生活化类比帮你记住这件事:学做菜,看菜谱”少许盐、适量油”,你觉得会了;真进厨房,才发现”少许”是多少、“适量”是几克,得亲手放一次、尝一次才懂。第 27 章是菜谱,这一章是进厨房开火。

落进引擎参数这件事,也因为动手而变得实在。你跑通之后,再回头看 Unity 里 transform.position、Camera 上的 FOV、Near、Far、Aspect——它们不再是神秘滑块,而是”喂给这六道关的具体数字”。你调 FOV,本质就是改投影矩阵里那个 f = 1/tan(fovy/2);你调相机位置,本质就是改 View 矩阵的反平移量。从”调滑块”到”改矩阵里的数”,这就是懂和会的分水岭。

32.2 最小矩阵 / 向量库:先造好”搬运工”

第 27 章反复强调一句话:变换 = 搬坐标,矩阵是搬运工公式。现在搬运工要下场干活了,我们得先给它造个趁手的身体。

先解决一个你大概率没细想过的问题:为什么顶点要写成 (x, y, z, w) 四个数,而不是 (x, y, z) 三个?

回第 27 章 27.1,我讲过一个反直觉的点:纯 3x3 矩阵,表示不了”平移”。 平移是加法(点 + 偏移),而 3x3 矩阵乘向量是线性变换,必然把原点 (0,0,0) 映射到原点——可平移要把原点搬到 (tx,ty,tz)。原点不再待在原点,所以平移不是线性变换,3x3 天生表示不了它。

工程师的解法漂亮且统一:升维成齐次坐标(Homogeneous Coordinates),写成 (x, y, z, w)。在这个四维空间里,用一个 4x4 矩阵就能同时表达”旋转 + 缩放 + 平移”——平移被巧妙地塞进矩阵的最后一列,靠 w 分量撑起来。

而 w 这个分量还背负着身份识别的使命:

  • w = 1 表示”这是一个点”(有位置,能被平移)。比如一个顶点局部坐标 (1, 1, -1, 1),那个 1 告诉矩阵”我是个可以挪来挪去的点”。
  • w = 0 表示”这是一个方向 / 向量”(比如法线、光线方向,没有位置,平移对它没意义)。移动一个”方向”本身是荒诞的——方向只有朝向,没有地址。

这一条你先死死记住,本章结尾思考题会让你用它想”为什么法线不能直接用模型矩阵乘”。

好,现在动手写库。我们用最朴素的方式:一个矩阵就是 4 行、每行 4 个数的二维列表 m[row][col](行优先)。向量是齐次坐标 [x, y, z, w]。矩阵乘向量,我们把向量当成”列向量”来乘,规则是:

out[i] = m[i][0]*v[0] + m[i][1]*v[1] + m[i][2]*v[2] + m[i][3]*v[3]

也就是第 i 个输出分量,等于矩阵第 i 行和向量做点积。你写 Go 时遍历切片做点积就是这个感觉。

下面是这一小段的核心函数(完整脚本在 32.5,这里先给你看最关键的两行骨架):

def mat_mul_vec4(m, v):
    """4x4 矩阵乘 4 维齐次向量,返回新的 vec4。把 v 当成列向量。"""
    return [
        m[0][0]*v[0] + m[0][1]*v[1] + m[0][2]*v[2] + m[0][3]*v[3],
        m[1][0]*v[0] + m[1][1]*v[1] + m[1][2]*v[2] + m[1][3]*v[3],
        m[2][0]*v[0] + m[2][1]*v[1] + m[2][2]*v[2] + m[2][3]*v[3],
        m[3][0]*v[0] + m[3][1]*v[1] + m[3][2]*v[2] + m[3][3]*v[3],
    ]

你注意到第 3 行 m[2][0]*v[0] + ... 了吗?这里 m[2] 是矩阵的”第 3 行”,和向量点乘得到输出的第 3 个分量。这个下标排法,就是”矩阵行 × 向量”的标准定义。如果你写成 m[0][i]*v[i] 那种(拿列去点),算出来的就是转置矩阵的效果——新手 90% 的 bug 都出在这个下标搞反。所以我现在就钉死:行优先存储 + 输出第 i 分量 = 矩阵第 i 行点乘向量。

矩阵乘矩阵也是一样的套路,只是把”向量”换成”矩阵的每一列”依次去乘。我后面会用到它把 Model、View、Projection 连乘成一个 MVP 矩阵(虽然这一章我们为了”看见每一步”,是分步乘、不连乘,但函数本身得有):

def mat_mul_mat(a, b):
    """矩阵乘法 C = A * B。C[i][j] = sum_k A[i][k] * B[k][j]。"""
    c = [[0.0]*4 for _ in range(4)]
    for i in range(4):
        for j in range(4):
            s = 0.0
            for k in range(4):
                s += a[i][k] * b[k][j]
            c[i][j] = s
    return c

你写 Go 时对这种三重循环绝不会陌生——就是个嵌套的 for,没什么魔法。图形学的矩阵运算,剥掉所有术语,底层就是它。

32.3 构造三个矩阵:平移 Model、视图 View、透视投影 Projection

搬运工的身体有了,现在造三个具体的搬运工:Model、View、Projection。这正是第 27 章 27.2–27.4 那三节的内容,今天把它们从公式变成代码里的几个数。

第一步:Model 矩阵(局部 → 世界)

第 27 章 27.2 说,Model 矩阵就是 Unity 里那个 Transform 的”平移 + 旋转 + 缩放”拼出来的 4x4。我们这章为了聚焦核心,只做平移(旋转缩放不影响”看见 w 和近大远小”,留给你自己加)。

平移矩阵的”长相”特别好记,我给你写出来你就永远忘不了:

[ 1  0  0  tx ]
[ 0  1  0  ty ]
[ 0  0  1  tz ]
[ 0  0  0  1  ]

看见没?左上角 3x3 是个单位阵(“保留原来的 x、y、z 不动”),真正的”搬运”只发生在最后一列 (tx, ty, tz)。点 (x,y,z,1) 乘上去:x' = x + tx,y' = y + ty,z' = z + tz,w' = 1。平移就这么被”塞”进了最后一列,靠 w=1 撑着。这就是第 27 章说的”平移必须 4x4”——3x3 根本没有这一列给你塞偏移。

我们这章把立方体整体挪到世界 (0, 0, -2),也就是相机前方 7 个单位处(相机在 (0,0,5),所以距离是 5-(-2)=7)。代码上就是 mat_translation(0, 0, -2)。

第二步:View 矩阵(世界 → 相机坐标)

这是反直觉最深的一步,我重点讲,因为它连回第 27 章 27.3 那句”移动相机 = 反向移动世界”。

你想,要让”世界里的物体”在”相机眼里”位置变化,有两种办法:①真的把物体挪动;②保持物体不动,把相机挪到相反方向——结果在相机看来完全一样。图形学选了后者在数学上的等价表达:视图矩阵,就是”相机自身 Transform 的逆矩阵”。

什么意思?相机在世界里也有一个 Transform(位置、朝向)。我们如果把这个 Transform 直接应用到世界上,那是”把世界按相机的方式摆”;但我们要的是”把世界摆到相机的坐标系里”,所以必须取逆(inverse)——把相机的平移反向、旋转反向。于是 View = inverse(CameraModel)。

为了让这件事不只是”一句漂亮话”,我在脚本里真的实现了一个通用 4x4 求逆函数 mat_inverse(用余子式 + 伴随矩阵法),然后实打实地算 view = mat_inverse(camera_model),再用”直接反平移” mat_translation(-cam) 对照,断言两者相等:

def make_view(cam_pos):
    camera_model = mat_translation(cam_pos[0], cam_pos[1], cam_pos[2])
    view = mat_inverse(camera_model)          # 真正取逆!
    view_direct = mat_translation(-cam_pos[0], -cam_pos[1], -cam_pos[2])
    assert mat_approx_eq(view, view_direct)   # 取逆 == 反平移,验证成立
    return view

你看到没?我把”View 是相机 Transform 的逆”这句话,从一个抽象断言,变成了一个 assert 能通过的代码事实。你跑脚本时这个 assert 没报错,就说明:取逆真的等于反平移。这就是动手的意义——它把”老师说的话”变成”你能验证的真”。

相机在 (0,0,5) 朝 -z 看(没旋转),所以 View 本质上就是把所有世界点”减去 (0,0,5)”。一个在世界 (11,1,1) 的点,到相机坐标里变成 (11,1,-4)——z 变成负的,表示它在相机”前方”(图形学惯例相机看向 -z)。

第三步:Projection 矩阵(相机坐标 → 裁剪空间,顺便设好 w)

第 27 章 27.4 说,投影矩阵做两件大事:把”平头截锥”的视锥压成”单位立方体”,并且设好 w。咱们用标准的透视投影矩阵(OpenGL 风格,NDC 的 z 落在 [-1,1]):

[ f/aspect   0          0                  0            ]
[ 0          f          0                  0            ]
[ 0          0          (far+near)/(near-far)   (2*far*near)/(near-far) ]
[ 0          0          -1                 0            ]

这里 f = 1/tan(fovy/2),fovy 就是视野角 FOV,aspect 是屏幕宽高比,near/far 是远近裁剪面。

请你盯住最后一行 [0, 0, -1, 0]。它干的事是:把输出向量的第 4 个分量(也就是 w)设成 -z_cam。注意输入向量的 z 在相机空间里是负的(在相机前方),所以 -z_cam 是正的——于是投影后 w = -z_cam > 0。

这就是第 27 章埋的那个钩子的兑现点:w 等于深度的负数。一个离相机 8 个单位的点,投影后 w = 8;离相机 6 个单位的点,w = 6。后面透视除法一除,w 大的(远的)被缩得更小,于是近大远小。你马上会在 32.5 的输出里亲手看到 w = 8.000 和 w = 6.000 这两个数,到那一刻,第 27 章的”w 是透视的灵魂”就从咒语变成了你算出来的结果。

顺便说一句:第 7 章你学过”近大远小是真实摄像机透视现象”,第 27 章说它来自 w。今天你看到的是同一件事的第三层——它来自投影矩阵最后那行 -1 和一次除法。现象(第 7 章)→ 公式(第 27 章)→ 可运行代码(本章),三层闭环。

32.4 搭一个立方体:8 个局部坐标顶点 + 一个相机

搬运工就位,现在得有”被搬的东西”。一个立方体有 8 个角,每个角在”模型自己的局部坐标”里,就是一个 (±1, ±1, ±1) 的组合。我们把每个都写成齐次坐标 (x, y, z, 1)——w=1,因为它们是”点”,能被 Model 平移。

def make_cube_vertices():
    s = 1.0  # 半边长,所以立方体从 -1 到 +1,边长 2
    raw = [
        (-1,-1,-1), ( 1,-1,-1), ( 1, 1,-1), (-1, 1,-1),
        (-1,-1, 1), ( 1,-1, 1), ( 1, 1, 1), (-1, 1, 1),
    ]
    return [[x, y, z, 1.0] for (x, y, z) in raw]  # 每个都 w=1

这 8 个顶点,前 4 个 z=-1(立方体的”后面”那一面),后 4 个 z=+1(“前面”那一面)。等会儿你会看到,因为 Model 把整体挪到了 z=-2,相机在 z=5,所以 z=+1 那一面离相机更近(距离 6),z=-1 那一面离相机更远(距离 8)。近的那一面在屏幕上应该显得更大——你马上就能在输出里验证。

相机就一个位置参数:cam_pos = [0.0, 0.0, 5.0],朝 -z 看。没有旋转,没有缩放,保持最简单,好让 w 和近大远小这两个主角不被别的噪声干扰。

小提醒:这里立方体是个”单位立方体”的放大版(半边长 1)。你可以改成 0.5 看它变小,改成 3 看它占满屏幕甚至被裁掉——这正是你动手玩参数的入口。

32.5 跑六步并打印:看着点从模型”飞”到屏幕

好,搬运工、被搬的东西都齐了。现在把它们串成第 27 章的六步流水线,对 8 个顶点逐一跑,把每一步的中间坐标都打印出来。你要的不是”算完给个最终答案”,而是”看见每一步中间发生了什么”——这才是戳破假懂的关键。

下面就是完整、可直接复制运行的脚本(只用标准库 math,不依赖 matplotlib;若你装了 matplotlib 它会顺手画张图,没装就退化为 ASCII 图)。请你把它存成 coord32.py,用 Python 3 跑一遍:

# -*- coding: utf-8 -*-
"""
第 32 章配套脚本:从零实现坐标变换六步(Model -> View -> Projection -> 透视除法 -> NDC -> Viewport)
只用 Python 标准库 math,不依赖任何第三方库。
可选:若安装了 matplotlib,则把 8 个顶点画在屏幕上;否则打印一张 ASCII 投影图。
"""
 
import math
 
 
# ---------------------------------------------------------------------------
# 第 1 块:最小矩阵 / 向量库
# 约定:所有矩阵都是 4x4,按"行优先"存成一个 list[list[float]],
# 一共 4 行,每行 4 个数:m[row][col]。
# 向量是齐次坐标 vec4 = [x, y, z, w]。
# 矩阵乘向量:out[i] = sum_j m[i][j] * v[j]
# 也就是把 v 当成"列向量"来乘。
# ---------------------------------------------------------------------------
 
def mat_identity():
    return [
        [1.0, 0.0, 0.0, 0.0],
        [0.0, 1.0, 0.0, 0.0],
        [0.0, 0.0, 1.0, 0.0],
        [0.0, 0.0, 0.0, 1.0],
    ]
 
 
def mat_translation(tx, ty, tz):
    """平移矩阵。把点 (x,y,z,1) 变成 (x+tx, y+ty, z+tz, 1)。
    注意平移被塞进了最后一列(col=3),这正是齐次坐标的妙处。"""
    m = mat_identity()
    m[0][3] = tx
    m[1][3] = ty
    m[2][3] = tz
    return m
 
 
def mat_mul_vec4(m, v):
    """4x4 矩阵乘 4 维齐次向量,返回新的 vec4。"""
    return [
        m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2] + m[0][3] * v[3],
        m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2] + m[1][3] * v[3],
        m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2] + m[2][3] * v[3],
        m[3][0] * v[0] + m[3][1] * v[1] + m[3][2] * v[2] + m[3][3] * v[3],
    ]
 
 
def mat_mul_mat(a, b):
    """矩阵乘法 C = A * B。C[i][j] = sum_k A[i][k] * B[k][j]。"""
    c = [[0.0] * 4 for _ in range(4)]
    for i in range(4):
        for j in range(4):
            s = 0.0
            for k in range(4):
                s += a[i][k] * b[k][j]
            c[i][j] = s
    return c
 
 
def mat_inverse(m):
    """通用 4x4 求逆(用余子式 + 伴随矩阵法)。
    我们用它来验证一句关键的话:View 矩阵 = 相机自身 Transform 的逆矩阵。"""
    def minor(matrix, r, c):
        # 返回去掉第 r 行第 c 列后的 3x3 行列式
        rows = [i for i in range(4) if i != r]
        cols = [j for j in range(4) if j != c]
        sub = [[matrix[rows[i]][cols[j]] for j in range(3)] for i in range(3)]
        return (
            sub[0][0] * (sub[1][1] * sub[2][2] - sub[1][2] * sub[2][1])
            - sub[0][1] * (sub[1][0] * sub[2][2] - sub[1][2] * sub[2][0])
            + sub[0][2] * (sub[1][0] * sub[2][1] - sub[1][1] * sub[2][0])
        )
 
    det = 0.0
    for c in range(4):
        det += ((-1) ** c) * m[0][c] * minor(m, 0, c)
    if abs(det) < 1e-12:
        raise ValueError("矩阵不可逆(行列式接近 0)")
 
    # 伴随矩阵:adj[i][j] = (-1)^(i+j) * minor(j, i)  (注意转置)
    adj = [[0.0] * 4 for _ in range(4)]
    for i in range(4):
        for j in range(4):
            adj[i][j] = ((-1) ** (i + j)) * minor(m, j, i)
    inv = [[adj[i][j] / det for j in range(4)] for i in range(4)]
    return inv
 
 
def mat_approx_eq(a, b, eps=1e-6):
    for i in range(4):
        for j in range(4):
            if abs(a[i][j] - b[i][j]) > eps:
                return False
    return True
 
 
# ---------------------------------------------------------------------------
# 第 2 块:构造三个矩阵
# ---------------------------------------------------------------------------
 
def make_model():
    """模型矩阵:把立方体从局部坐标搬到世界坐标。
    这里把立方体整体平移到世界 (0, 0, -2),即相机前方 7 个单位处。"""
    return mat_translation(0.0, 0.0, -2.0)
 
 
def make_view(cam_pos):
    """视图矩阵:世界坐标 -> 相机坐标。
    关键直觉:移动相机 = 反向移动世界,所以 View = inverse(相机自身的 Transform)。
    这里相机只有平移(无旋转),相机 Transform 就是 translation(cam_pos),
    它的逆就是 translation(-cam_pos)。我们用 mat_inverse 实打实算一遍验证。"""
    camera_model = mat_translation(cam_pos[0], cam_pos[1], cam_pos[2])
    view = mat_inverse(camera_model)  # 真正取逆!
    view_direct = mat_translation(-cam_pos[0], -cam_pos[1], -cam_pos[2])
    assert mat_approx_eq(view, view_direct), "取逆结果和反平移不一致?"
    return view
 
 
def make_projection(fovy_deg, aspect, near, far):
    """透视投影矩阵(OpenGL 风格,NDC 的 z 落在 [-1, 1])。
    公式:
      f = 1 / tan(fovy/2)
      [ f/aspect, 0,        0,                            0            ]
      [ 0,        f,        0,                            0            ]
      [ 0,        0,        (far+near)/(near-far),        (2*far*near)/(near-far) ]
      [ 0,        0,        -1,                           0            ]
    注意最后一行把 w 设成了 -z_cam:投影后 w = -z_cam(z_cam 为负,故 w>0)。
    正是这个 w,让透视除法能产生"近大远小"。"""
    f = 1.0 / math.tan(math.radians(fovy_deg) / 2.0)
    return [
        [f / aspect, 0.0, 0.0, 0.0],
        [0.0, f, 0.0, 0.0],
        [0.0, 0.0, (far + near) / (near - far), (2.0 * far * near) / (near - far)],
        [0.0, 0.0, -1.0, 0.0],
    ]
 
 
# ---------------------------------------------------------------------------
# 第 3 块:立方体与相机
# ---------------------------------------------------------------------------
 
def make_cube_vertices():
    """立方体 8 个局部坐标顶点,每个都写成齐次坐标 (x, y, z, 1)。
    w=1 表示"这是一个点",可以被平移。"""
    s = 1.0  # 半边长
    raw = [
        (-s, -s, -s), (s, -s, -s), (s, s, -s), (-s, s, -s),
        (-s, -s,  s), (s, -s,  s), (s, s,  s), (-s, s,  s),
    ]
    return [[x, y, z, 1.0] for (x, y, z) in raw]
 
 
# ---------------------------------------------------------------------------
# 第 4 块:跑六步并打印
# ---------------------------------------------------------------------------
 
def run_pipeline():
    # ---- 参数(鼓励你改这里再跑)----
    cam_pos = [0.0, 0.0, 5.0]   # 相机在世界 (0,0,5),朝 -z 看向场景
    fovy_deg = 60.0
    aspect = 8.0 / 6.0           # 800x600 的宽高比
    near, far = 1.0, 100.0
    width, height = 800, 600     # 视口(屏幕)像素尺寸
 
    model = make_model()
    view = make_view(cam_pos)
    proj = make_projection(fovy_deg, aspect, near, far)
    verts = make_cube_vertices()
 
    print("=" * 72)
    print("参数:相机%s  视野角%.0f°  宽高比%.3f  near=%.1f far=%.1f  视口%dx%d"
          % (cam_pos, fovy_deg, aspect, near, far, width, height))
    print("立方体整体被 Model 平移到世界 (0,0,-2),相机在 (0,0,5),所以立方体离相机约 7 个单位。")
    print("=" * 72)
 
    ndc_points = []
    screen_points = []
 
    for idx, v_local in enumerate(verts):
        # ① Model:局部 -> 世界
        v_world = mat_mul_vec4(model, v_local)
        # ② View:世界 -> 相机坐标
        v_cam = mat_mul_vec4(view, v_world)
        # ③ Projection:相机坐标 -> 裁剪空间(此时 w 已经不是 1)
        v_clip = mat_mul_vec4(proj, v_cam)
        # ④ 视锥裁剪(简化判断):用 x/w, y/w, z/w 看是否在 [-1,1]
        cx, cy, cz, cw = v_clip
        ndc_x_t = cx / cw
        ndc_y_t = cy / cw
        ndc_z_t = cz / cw
        inside = (-1 <= ndc_x_t <= 1) and (-1 <= ndc_y_t <= 1) and (-1 <= ndc_z_t <= 1)
        # ⑤ 透视除法:除以 w -> NDC
        v_ndc = [cx / cw, cy / cw, cz / cw]
        ndc_points.append((v_ndc[0], v_ndc[1]))
        # ⑥ Viewport:NDC -> 屏幕像素
        px = (v_ndc[0] + 1.0) / 2.0 * width
        py = (1.0 - (v_ndc[1] + 1.0) / 2.0) * height  # 屏幕 y 向下,翻转一下
        screen_points.append((px, py))
 
        print("\n--- 顶点 %d  局部%s" % (idx, [round(x, 3) for x in v_local]))
        print("  ① Model (局部->世界)    : %s" % fmt(v_world))
        print("  ② View  (世界->相机)    : %s   相机空间里 z=%.3f(负表示在相机前方)"
              % (fmt(v_cam), v_cam[2]))
        print("  ③ Proj  (->裁剪空间,带w): %s   <-- 注意这里的 w = %.3f(不再是 1!)" % (fmt(v_clip), cw))
        print("  ④ 裁剪判断: %s  (x/w,y/w,z/w)=(%.3f,%.3f,%.3f)"
              % ("在视锥内" if inside else "被裁掉", ndc_x_t, ndc_y_t, ndc_z_t))
        print("  ⑤ 透视除法(÷w -> NDC)   : %s   NDC 应落在 [-1,1]" % fmt3(v_ndc))
        print("  ⑥ Viewport(NDC->像素)   : 屏幕像素 (%6.1f, %6.1f)" % (px, py))
 
    return ndc_points, screen_points, (width, height)
 
 
def fmt(v4):
    return "[%8.3f %8.3f %8.3f %8.3f]" % (v4[0], v4[1], v4[2], v4[3])
 
 
def fmt3(v3):
    return "[%8.3f %8.3f %8.3f]" % (v3[0], v3[1], v3[2])
 
 
# ---------------------------------------------------------------------------
# 第 5 块:可视化(可选)
# ---------------------------------------------------------------------------
 
def draw_ascii(ndc_points, width=48, height=24):
    grid = [[' '] * width for _ in range(height)]
    for (nx, ny) in ndc_points:
        gx = int((nx + 1.0) / 2.0 * (width - 1))
        gy = int((1.0 - (ny + 1.0) / 2.0) * (height - 1))
        gx = max(0, min(width - 1, gx))
        gy = max(0, min(height - 1, gy))
        grid[gy][gx] = '+'
    print("\n" + "=" * 72)
    print("ASCII 投影图(8 个顶点在屏幕上的位置,'+' 为顶点):")
    print("=" * 72)
    for row in grid:
        print("".join(row))
 
 
def draw_matplotlib(ndc_points, screen_points, size):
    try:
        import matplotlib
        matplotlib.use("Agg")
        import matplotlib.pyplot as plt
    except Exception as e:
        print("\n(未安装 matplotlib,跳过绘图,只显示 ASCII 图。错误:%s)" % e)
        return
 
    fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4))
    xs = [p[0] for p in ndc_points]
    ys = [p[1] for p in ndc_points]
    ax1.scatter(xs, ys, color='red')
    ax1.set_xlim(-1.2, 1.2)
    ax1.set_ylim(-1.2, 1.2)
    ax1.set_aspect('equal')
    ax1.set_title("NDC 空间 (x,y in [-1,1])")
    ax1.grid(True)
 
    sx = [p[0] for p in screen_points]
    sy = [p[1] for p in screen_points]
    ax2.scatter(sx, sy, color='blue')
    ax2.set_xlim(0, size[0])
    ax2.set_ylim(size[1], 0)
    ax2.set_aspect('equal')
    ax2.set_title("屏幕像素坐标")
    ax2.grid(True)
 
    out = "coord32_plot.png"
    fig.savefig(out)
    print("\n(matplotlib 已绘制:%s)" % out)
 
 
if __name__ == "__main__":
    ndc_pts, screen_pts, size = run_pipeline()
    draw_ascii(ndc_pts)
    draw_matplotlib(ndc_pts, screen_pts, size)
    print("\n全部六步跑完。你可以改 cam_pos / fovy_deg / near / far 再跑一次,")
    print("观察 NDC 是否始终落在 [-1,1]、w 是否随深度变化、近大远小是否出现。")

跑完之后,挑顶点 0(局部 (-1,-1,-1))和顶点 4(局部 (-1,-1, 1))对比着看,我给你解读我机器上出来的关键几行:

  • 顶点 0:Model 后世界 (-1,-1,-3),View 后相机 (-1,-1,-8)(z=-8,离相机 8 个单位),投影后变成 [ -1.299, -1.732, 6.141, 8.000 ]——注意最后那个 w = 8.000,它不再是 1。透视除法后 NDC = [-0.162, -0.217, 0.768],全都在 [-1,1] 里,然后 Viewport 映射到像素 (335, 365)。
  • 顶点 4:Model 后世界 (-1,-1,-1),View 后相机 (-1,-1,-6)(z=-6,离相机 6 个单位,更近),投影后 [ -1.299, -1.732, 4.101, 6.000 ]——这里的 w = 6.000,比顶点 0 的 8 小。透视除法后 NDC = [-0.217, -0.289, 0.684],然后 Viewport 映射到像素 (313, 387)。

你盯着这两个 NDC 的 x、y 绝对值看:顶点 4(近,w=6)的 NDC 是 0.217 / 0.289,顶点 0(远,w=8)的 NDC 是 0.162 / 0.217。近的 NDC 绝对值更大,意味着它在屏幕上离中心更远、显得更大——这就是”近大远小”,而且是这一次 ÷w 直接计算出来的,不是我告诉你的,是你跑出来的。

讲到这里,我想多停留一步,把”为什么偏偏是除法、而不是别的运算制造了透视”这个点彻底钉死,因为这是第 27 章最容易被”假懂”的角落。

你拿一个手电筒或投影仪照墙,离墙近时光斑小、离墙远时光斑大——这是物理上的”发散”。透视投影在数学上干的是相反方向的同一件事:它要让”离相机远的物体,在屏幕上占的位置更小”。怎么做到?用一个和深度成反比的因子去缩放 x、y。而”反比”在数学上就是除法:屏幕位置 = 原始位置 / 深度。深度越大(越远)除完越小,深度越小(越近)除完越大。所以不是工程师拍脑袋选了除法,而是”越远越小”这个物理事实,天然就是一个除法关系。w 在这里就是那个”深度因子”的载体:投影矩阵把它设成 -z_cam(深度),透视除法 x/w 就是”用深度去除位置”。

这还顺手连回了第 26 章。第 26 章 26.4 我说过:深度缓冲里的值、以及它的”近密远疏”精度分布,根子都在 w 和透视除法。你今天看到 w=8、w=6,正是第 26 章那个”w 伏笔”的实体——同一个 w,一边制造了近大远小(这章),一边制造了深度精度的非线性(第 26 章)。两章在 w 这个点上严丝合缝地咬合成一条因果链:投影设 w → 除法用 w → 屏幕近大远小 + 深度近密远疏。

你再想想”为什么 w 必须正好是 -z_cam 而不是随便一个数”。如果 w 是个常数(比如永远 =1,这正是正交投影的做法),那 x/w 就只是 x 乘个固定比例,远近一样大,没有透视——这正是第 27 章说的”正交投影 w=1、故无近大远小”。只有让 w 随深度变(透视投影 w=-z_cam),除法才会对远近产生不同的缩放,近大远小才出现。所以 w 是不是”随深度变化”,是透视和正交的本质分水岭,而你今天亲手算出的 w=8 和 w=6,就是这道分水岭上两个具体的水位线。

我请你现在就做三件事(动手才算数):

  1. 把相机 cam_pos 从 [0,0,5] 改成 [0,0,3],再跑——你会发现 w 变小、NDC 绝对值变大、立方体在屏幕上”扑过来”变大了。这对应第 7 章”相机靠近物体,物体显得更大”。
  2. 把 fovy_deg 从 60 改成 100(广角 / 鱼眼),再跑——w 没变,但投影矩阵里的 f 变小,x、y 被压缩得少,立方体显得更大更”凸”,像用了广角镜头。
  3. 把立方体 make_model 的 z 改成 -30(推到远处),再跑——你会看到某些顶点被”裁掉”(第 ④ 步输出”被裁掉”),因为超出了视锥。这就是第 27 章 27.5 的视锥裁剪,活生生发生在你眼前。

32.6 直观看见:ASCII 投影图与可选的 matplotlib

光看数字还不够”看见”。脚本在跑完六步后,会画一张 ASCII 投影图——把 8 个顶点的 NDC 坐标塞进一张字符网格,+ 就是顶点位置。我机器上大概长这样(具体因网格分辨率略有差异):

                                               
                                               
                                               
                                               
                                               
                                               
                                               
                  +         +                  
                   +       +                   
                                               
                                               
                                               
                   +       +                   
                  +         +                  
                                               
                                               
                                               
                                               

你看这两组 +:上面一组(来自 z=-1 的远面,w=8)间距窄,下面一组(来自 z=+1 的近面,w=6)间距宽。两张”口”字,近的更大、远的更小——这就是立方体被透视投影压扁后的样子,一个最朴素的”透视画”。你在脑海里把它补全成线框,就是一个近大远小的立方体。

如果你装了 matplotlib(命令行 pip install matplotlib 即可),脚本会自动画两张并排的散点图:左边是 NDC 空间里 8 个点的分布(正方形框 [-1,1]),右边是映射到 800×600 屏幕像素后的分布。你会更直观地看见:左边点都在方框内(验证 NDC 规范区间),右边点按透视排布、近面铺得更开。

关于 matplotlib 我要提醒一句:它是可选的。本章所有核心验证——w 不为 1、透视除法、NDC 落 [-1,1]、近大远小——都不依赖它,纯标准库就能完成。装它是为了”好看”,不是”必须”。你如果只想最快验证原理,跑标准库版本足矣;想看图像再装。别让”还得装个库”成为你不动手借口。

这里还有个细节值得你琢磨:ASCII 图和 matplotlib 图里,点的”相对大小关系”完全相同,因为两者都来自同一组 NDC。区别只是”画在字符网格”还是”画在像素画布”。这恰好呼应第 27 章 27.7 的 Viewport 变换——NDC 是正方形的规范空间,Viewport 把它拉伸/映射到具体屏幕尺寸。你改 aspect(宽高比),ASCII 里窄、matplotlib 里也会被压扁成对应比例,正好演示了”投影矩阵的 aspect 没对齐屏幕,画面会变形”那个坑。

32.7 这章和第 27 章的关系:公式不再是天书

咱们把今天干的活,扣回第 27 章那六道关,看看你到底验证了什么。

你亲手验证了第一件事:“w 产生近大远小”不是比喻,是除法。 第 27 章 27.6 说”透视除法那一步产生近大远小”,你今天看到的是:投影矩阵最后一行 [0,0,-1,0] 把 w 设成了 -z_cam(远的 w=8、近的 w=6),然后 x/w, y/w 一除,远的被缩得更小。公式 x_ndc = x_clip / w 从一句需要背诵的咒语,变成了你打印出来、亲眼看见的三个数字。下次面试官问”近大远小怎么来的”,你不用背,直接说”投影设 w 为深度负值、透视除一下”,还能顺手写出那行 [0,0,-1,0]。

你亲手验证了第二件事:“平移必须 4x4”不是规定,是数学逼迫。 第 27 章 27.1 说纯 3x3 表示不了平移,你今天写的 mat_translation 把偏移塞进了第 4 列,靠 w=1 让点能被平移、靠 w=0 让方向不被平移。你写的 mat_mul_vec4 对 w=1 的点正确平移、对 w=0 的方向向量会跳过平移列——这就是齐次坐标两条铁律在代码里的样子。

你亲手验证了第三件事:“View 是相机 Transform 的逆”不是玄学,是 assert 过的。 make_view 里 mat_inverse(camera_model) 和直接反平移 mat_translation(-cam) 被我 assert 成相等。你跑脚本时这个 assert 没崩,就说明取逆真的等于反平移——第 27 章 27.3 那句”移动相机 = 反向移动世界”,从一句漂亮话变成了你能验证的代码事实。

至此,第 27 章那条链对你来说,从”看懂的解说”变成了”会下的棋”。你可以回去看第 27 章 27.9 那个手动推演的顶点,对照你今天跑出来的数字——你会发现,书上那串整数是你今天这些浮点数的”取整版”,两者是同一套算术。这种”书和我的代码对上了”的踏实感,就是动手的奖励。

再往上看一眼全书:第 2 章给你埋了”变换 = 搬坐标,矩阵是搬运工”的钩子;第 7 章讲真实摄像机的近大远小;第 26 章讲每个像素都有深度、z 精度近密远疏(根子就在 w);第 27 章把六步讲透;今天第 32 章,你亲手把这六步跑成了代码。现象(第 2、7 章)→ 公式(第 26、27 章)→ 可运行验证(第 32 章),一条完整闭环。下一张(第 33 章)我们换个战场,去 shader 里亲手写”雾”,把第 8 章的雾、第 26 章的深度,也变成你能验证的代码。

32.8 思考题与参考答案

【思考题】

  1. 如果你在 32.5 的脚本里,算完投影坐标后,忘了做”透视除法”(第 ⑤ 步),直接把裁剪空间的 (cx, cy, cz, cw) 当成屏幕坐标去画,会发生什么?为什么直接画出来的点会”飞到屏幕外”或”位置全错”?
  2. 这一章我们说 View 矩阵是”相机自身 Transform 的逆矩阵”,并用 assert 验证了”取逆 == 反平移”。请你用自己的话解释:为什么是”取逆”而不是”直接把相机 Transform 拿来用”?如果错误地用正向的相机 Transform 当 View,世界会怎样?
  3. 顶点 0 投影后 w=8.000、顶点 4 投影后 w=6.000。这两个 w 分别是它们”离相机深度”的负数。请用这两个数字,具体说明”为什么近大远小是 ÷w 那一步产生的”——即:同样一个局部 x=1 的顶点,经过 ÷w 之后,近的和远的在 NDC 上的差别是什么?
  4. 第 27 章 27.1 提到,法线是”方向”,在齐次坐标里应该是 w=0。如果我们把一个法线 (0,1,0,0) 错误地用 mat_mul_vec4(model, 法线) 去乘(model 是一个带平移的矩阵),会发生什么怪事?这反过来说明,为什么”点用 w=1、方向用 w=0”不是随便定的,而是被平移列的存在逼出来的?

【参考答案】

  1. 投影矩阵算完的裁剪空间坐标是带 w 的齐次坐标,x、y 还没”除回来”。如果跳过透视除法直接拿 cx, cy 当屏幕坐标,那它们其实还处在”被 w 放大过”的状态:比如顶点 0 的 cx=-1.299 看起来还行,但真正的屏幕坐标应该是 cx/w = -1.299/8 ≈ -0.162 再映射。更糟的是,如果直接把四维 (cx,cy,cz,cw) 里的 cw(比如 8)当成某个轴,数值会完全失控、点飞出屏幕。根本原因是:裁剪空间的坐标必须除以 w 才回到”人能理解的 NDC 区间 [-1,1]“;不忘除以 w,等于把”还没归一化”的中间产物当成了结果。这也是为什么第 27 章把”透视除法”单独列为一道关——它不可省略。
  2. View 的目的是”把全世界的点,用相机为原点重新描述”。相机在世界里有个 Transform(它在哪、朝哪)。如果直接把这个 Transform 用到世界上,那是”把世界按相机的方式摆”(方向反了,等于把世界往相机方向上推了一把);我们要的是”让相机待在原点、世界相对它摆放”,所以必须取逆。具体到只有平移的情况:相机在 (0,0,5),正向 Transform 是”世界 +(0,0,5)“,那会把本来在 z=-2 的立方体推到 z=3,离相机更近了,完全反了;而取逆是”世界 -(0,0,5)“,立方体到 z=-7(相机坐标系里 z=-7,即相机前方 7 单位),才正确。所以我用 mat_inverse 取逆,再 assert 它等于”直接反平移”——两者一致,证明取逆在只有平移时就是反平移。
  3. 假设两个顶点局部 x 都是 1,投影后它们的 cx 都约等于 1.299(被同一个 f/aspect 系数缩放)。但顶点 0 的 w=8、顶点 4 的 w=6。透视除法后:顶点 0 的 ndc_x = 1.299/8 ≈ 0.162,顶点 4 的 ndc_x = 1.299/6 ≈ 0.217。同样一个局部 x=1,近的(w 小)除完更大、离屏幕中心更远、显得更大;远的(w 大)除完更小、离中心更近、显得更小。所以”近大远小”严格发生在 ÷w 这一算术操作上,w 越大(越远)缩小得越狠——这就是公式落进数字的证据。
  4. 法线 (0,1,0,0) 是方向(w=0)。用带平移的 model 矩阵去乘时,按 mat_mul_vec4 的算法,输出的第 4 个分量 w' = m[3][0]*0 + m[3][1]*1 + m[3][2]*0 + m[3][3]*0 = m[3][1],而平移矩阵里 m[3][1]=0,看起来 w 还是 0——但更隐蔽的坑在平移列会污染前三个分量:输出的 x' = m[0][0]*0 + m[0][1]*1 + m[0][2]*0 + m[0][3]*0 = m[0][1],而 m[0][3] 是 x 方向平移量、乘的是 v[3]=0 所以没加进来——等等,这里因为 v[3]=0,平移列 m[0][3],m[1][3],m[2][3] 全部乘以 0,所以平移确实没影响方向的大小。真正出问题的是非均匀缩放(x 放大 2 倍、y 不变),它会把法线扭歪,让法线不再垂直表面,光照(第 6 章 N·L)算错。这反过来说明:w=0 让”平移列不参与”(方向没位置可平移),而旋转/缩放部分仍需特殊处理(用逆转置)。所以”点 w=1、方向 w=0”不是约定俗成,而是被”平移列的存在”和”缩放会扭歪方向”这两件事共同逼迫出来的——齐次坐标用 w 把”点”和”方向”在数学上干净地分了家。