第 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,就是这道分水岭上两个具体的水位线。
我请你现在就做三件事(动手才算数):
- 把相机
cam_pos从[0,0,5]改成[0,0,3],再跑——你会发现 w 变小、NDC 绝对值变大、立方体在屏幕上”扑过来”变大了。这对应第 7 章”相机靠近物体,物体显得更大”。 - 把
fovy_deg从 60 改成 100(广角 / 鱼眼),再跑——w 没变,但投影矩阵里的f变小,x、y 被压缩得少,立方体显得更大更”凸”,像用了广角镜头。 - 把立方体
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 思考题与参考答案
【思考题】
- 如果你在 32.5 的脚本里,算完投影坐标后,忘了做”透视除法”(第 ⑤ 步),直接把裁剪空间的
(cx, cy, cz, cw)当成屏幕坐标去画,会发生什么?为什么直接画出来的点会”飞到屏幕外”或”位置全错”? - 这一章我们说 View 矩阵是”相机自身 Transform 的逆矩阵”,并用
assert验证了”取逆 == 反平移”。请你用自己的话解释:为什么是”取逆”而不是”直接把相机 Transform 拿来用”?如果错误地用正向的相机 Transform 当 View,世界会怎样? - 顶点 0 投影后 w=8.000、顶点 4 投影后 w=6.000。这两个 w 分别是它们”离相机深度”的负数。请用这两个数字,具体说明”为什么近大远小是 ÷w 那一步产生的”——即:同样一个局部 x=1 的顶点,经过 ÷w 之后,近的和远的在 NDC 上的差别是什么?
- 第 27 章 27.1 提到,法线是”方向”,在齐次坐标里应该是
w=0。如果我们把一个法线(0,1,0,0)错误地用mat_mul_vec4(model, 法线)去乘(model 是一个带平移的矩阵),会发生什么怪事?这反过来说明,为什么”点用 w=1、方向用 w=0”不是随便定的,而是被平移列的存在逼出来的?
【参考答案】
- 投影矩阵算完的裁剪空间坐标是带 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 章把”透视除法”单独列为一道关——它不可省略。 - 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它等于”直接反平移”——两者一致,证明取逆在只有平移时就是反平移。 - 假设两个顶点局部 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 越大(越远)缩小得越狠——这就是公式落进数字的证据。 - 法线
(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 把”点”和”方向”在数学上干净地分了家。