ARTICLE · 1294021
与AI一起从零开始的GAMES101之旅 02(中)|投影、三个判据与叉乘
Lecture 02 · Review of Linear Algebra | 闫令琪(UCSB)| 原视频 BV1X7411F744 第 2 集
上篇讲向量、坐标和点乘的定义。这一篇讲点乘怎么用,以及第二种乘法:叉乘。
这是《与AI一起从零开始的GAMES101之旅》的第 2 篇(中)。素材来自闫令琪老师的公开课;和上篇一样,AI 先整理出初稿,我逐节复核改写。不看上篇也能顺着读——这里需要的前置只有一句:点乘 a · b = ‖a‖‖b‖ cos θ,结果是一个数。
课程版权归闫令琪老师及原课程团队所有。这里只做学习记录,不转载视频、不提供课件。
这一篇讲什么
三节的顺序是有讲究的:05 教你把一个向量拆成“沿某方向”和“垂直某方向”两块,06 用拆出来的东西做判断,07 换一种乘法,制造出一个新的方向。
05 · 投影:把一个向量拆成两个
原视频 P2 22:00–26:05(约 4 分钟)
这一节解决什么
投影是点乘最重要的几何应用。它解决的问题是:给定两个向量,我想知道 b 在 a 的方向上“有多少”。
听起来像个纯数学练习,但它是后面很多技术的骨架:法线方向的分解、Lambert 光照、镜面反射方向、屏幕空间投影……全是这一件事的不同外衣。
几何图像
想象一束平行光垂直照向 a 的方向,b 在 a 上投下一段阴影。那段阴影就是 b 在 a 上的投影。
课程用的正是这个比喻:“假设有一束光线平行着垂直向 a 方向照过来,b 自然会投出一个阴影在 a 上。”
记号上写作 b⊥(perp = perpendicular,垂直的那个分量)。
怎么算
投影一定沿着 a 的方向,所以它的方向是确定的——就是 â。唯一要算的是长度。
从直角三角形看:b、它的投影 b⊥、和垂直分量,三者构成直角三角形,b 是斜边。于是:
‖b⊥‖ = ‖b‖ cos θ
而 cos θ 从哪来?点乘。
所以完整的投影:
b⊥ = (‖b‖ cos θ) â = (b · â) â
第二个等号用了 ‖b‖ cos θ = b · â,因为 â 长度为 1。
这就是为什么图形学偏爱单位向量:如果 a 不是单位向量,公式得写成
b⊥ = (a · b) / ‖a‖² · a
——多一次平方、多一次除法。代码里先归一化再点乘,通常更快也更不容易错。
分解:投影的真正用途
算出投影后,另一个分量就是减法:
b 沿 a 的分量 = b⊥,b 垂直于 a 的分量 = b − b⊥
(下面统一用 b∥ 记沿 a 的分量,b − b∥ 记垂直分量。)
课程的说法是“把一个向量任意的进行垂直与平行的分解,这是一个很重要的事情”。
重要在哪?给你三个具体场景:
场景一:光照的漫反射项。 光线方向 l 照到表面,我们只关心它在法线方向上贡献了多少(那是真正进来照到物体的量),垂直于法线的部分沿着表面“滑走”不产生亮度。所以亮度正比于 l 在法线上的投影长度——也就是 n · l 那个点乘。
场景二:镜面反射方向。 入射光 l 打在法线为 n 的表面上,反射方向可以直接用分解算出来:
r = 2(l · n) n − l
推导一句话:把 l 分解成沿法线的 l∥ 和垂直的 l − l∥;反射时垂直分量不变,法向分量取反。加起来就是上面的式子。第十七讲讲 BRDF 时这是核心。
场景三:去掉某个方向的运动。 角色沿斜坡走,你想让它的速度只保留沿坡面方向——把速度投影到坡面的法线上、减去那部分,剩下的就在坡面内。物理模拟里到处都是这个操作。
代码
用 Eigen(GAMES101 作业用的线性代数库)写出来是四行:
// v 在 u 方向上的投影;u 必须非零Vector3f project(const Vector3f& v, const Vector3f& u) { return u.normalized() * v.dot(u.normalized());}Vector3f para = project(v, u); // 平行分量Vector3f perp = v - para; // 垂直分量normalized() 每次都会重新开方,实际写渲染器时应该把归一化后的向量算一次存下来复用,别在循环里反复调用。这是个很常见的性能问题。
一个必须注意的边界: 如果 b 和 a 方向相反(夹角大于 90°),cos θ 是负数,投影长度是负的。这在数学上是正确的——投影落在反方向。但如果你拿它当“亮度”用,一定要 clamp 到 0,否则会出现负光照(画面变黑甚至溢出)。这就是上一节那个 max(0, n · l) 的由来。
验收
- 能手算投影。
b = (3,4),a = (1,0)。求 b 在 a 上的投影和垂直分量。再把 a 换成 (1,1) 算一次。 - 能推导反射公式。
不看上面,自己从“垂直分量不变、法向分量取反”推出 r = 2(l · n)n − l。 - 故意制造一个错误。
用 b⊥ = (b · a)a 算投影,忘了归一化,而 a 长度是 3。结果会差多少?——差 9 倍(因为少除了 ‖a‖²)。这个 bug 的症状是“图形缩放时某个偏移量跟着放大”,是典型的“忘了除以模长平方”错误。
合上页面,回答这三个问题
投影公式里为什么会出现“单位向量点乘”这种组合? 用分解解释镜面反射公式的推导。 - (超出本节)
如果 a 是零向量,投影还有定义吗?
第 3 题的参考答案:没有。 因为“沿 a 的方向”不存在——零向量没有方向,â 无法定义,公式里要除以 ‖a‖² = 0。代码里必须先判零,否则直接得到 NaN 或者触发浮点异常。渲染器里所有“按法线分解”的地方都要做这个检查:退化三角形(面积为 0)的法线就是零向量。
06 · 用一个点乘回答三个问题:前、后、有多近
原视频 P2 26:05–30:15(约 4 分钟)
这一节解决什么
上一节讲了点乘怎么算投影。这一节讲点乘的符号和大小怎么直接当作判据用——这是图形学里最省算力的技巧之一:一个数,同时给出方向和程度。
这三个判据(前/后、有多近、是否垂直)会在第七讲的 Phong 光照、第十三讲的光线求交、第五讲的三角形内外判定里反复出现。现在把它们一次记牢,后面就不用每次重新推。
判据一:正负号告诉你“前还是后”
设 a 已经确定(当作“前方”)。把 a 的起点当作圆心画一个圆,用一条垂直于 a 的直径把它切成两半:
任何落在前半圆的向量 b,a · b > 0 任何落在后半圆的向量 c,a · c < 0 正好落在分界线上的向量,a · b = 0
为什么?因为 a · b = ‖a‖‖b‖ cos θ,前两个因子总是正的,符号完全由 cos θ 决定:
课程的原话是“点乘可以告诉我们方向性:方向基本一致、方向基本相反、或者方向垂直”。
这个判据非常便宜:三次乘法两次加法,不需要开方、不需要三角函数。所以凡是“这个面朝向摄像机吗”“这个点在平面哪一侧”“这一块要不要剔除”这类问题,第一反应都应该是算点乘的符号。
判据二:大小告诉你“有多近”
如果两个向量都是单位的(只表示方向),那么点乘结果被限制在 [−1, 1] 里,而且形成一条单调的相似度刻度:
课程说“这个值可以告诉大家这两个向量在方向上有多么接近”。这句话的用处是:你不需要知道角度,就可以按“接近程度”做插值、做衰减、做阈值判断。
判据三(应用):高光为什么是一个点乘
课程用一个镜子解释了这件事:
光打在镜面上,只有当你正好站在镜面反射方向上看过去,才会看到那个刺眼的亮点;偏一点点就看不到了。金属的高光同理——它不是一个点,而是围绕反射方向的一个“亮斑”,离反射方向越远越暗。
于是渲染里描述高光的方式就自然是:
镜面项 ∝ (R · V)ⁿ
R 是反射方向(上一节的公式算出来) V 是指向观察者的方向 R · V 越接近 1,说明你越正对着反射方向,越亮 那个 n 次方是“锐化”:n 越大,亮斑越小越集中(金属和塑料的差别主要在这里)
这就是 Phong 光照模型的镜面项。第七讲会正式讲,现在你只需要理解:整个高光效果,建立在一个点乘上。
(这里补一个后话:Blinn-Phong 改用了半程向量 H = normalize(l + V),用 N · H 代替 R · V。好处是不用算反射方向,省一次运算,而且高光形状更自然。这个改进你在第八讲会看到。)
一个工程判断:能比 cos 就别比角度
上一节说过“很少真用 arccos”。这里给一个具体应用:
问题:判断一个方向是否在某个锥角内(比如聚光灯的照射范围)。
笨办法:
float angle = std::acos(normalize(d).dot(normalize(axis)));if (angle < cutoff) { /* 亮了 */ }开销:两次归一化(每次一个 sqrt)+ 一次 acos。
正确办法:
// cutoff_cos 是 cutoff 的余弦,提前算好if (normalize(d).dot(normalize(axis)) > cutoff_cos) { /* 亮了 */ }省掉了 acos,而且数值上更稳——cos 在 1 附近变化很剧烈,反解会放大误差。
这个思路可以推广成一条原则:所有“角度阈值”的判断,都改写成“余弦阈值”的比较。 你会在这门课的很多地方看到它的影子。
验收
- 能判符号。
a = (1,0,0)。下面三个向量分别落在 a 的前面、侧面还是后面:(2,3,0)、(0,5,0)、(−1,0.5,0)? - 能建立刻度感。
两个单位向量点乘得 0.707,夹角大约多少?(答案:45°。cos 45° ≈ 0.707,这个数值得记住,是渲染里最常见的分界。) - 故意制造一个错误。
判断“这个面朝向相机吗”时,用法线和从相机到该点的方向做点乘,结果所有背面都判定为朝向相机。为什么?——因为方向反了。应该用从该点到相机的方向。点乘的符号对“谁是起点”极其敏感,而两个向量看起来长得一样。这是图形学里最经典的一类 bug:法线方向对不对,只影响符号。
合上页面,回答这三个问题
为什么点乘的符号就能判断“前/后”?这个结论依赖哪两个前提? Phong 高光为什么用 R · V 而不是 R · L? - (超出本节)
如果一个“高光”用 (R · V)¹⁰⁰ 计算,另一个用 (R · V)⁵,哪个看起来更像金属?为什么?
第 3 题的参考答案:n = 100 的更像金属。因为指数越大,只有非常接近反射方向的地方才亮,形成一个小而锐的亮斑——这正是抛光金属的特征。n = 5 时亮斑弥散,看起来像塑料或磨砂表面。指数 n 实际上在控制“表面有多光滑”,这也是粗糙度(roughness)参数的最早形式。
07 · 叉乘:生产出一个垂直的向量
原视频 P2 30:15–36:05(约 6 分钟)
这一节解决什么
点乘给你一个数,叉乘给你一个向量——而且是一个垂直于原来两个向量的新向量。
这件事的价值在于:它让你能“生成”一个方向。 已知两条边,就能造出第三根轴;已知一个平面,就能得到它的法线。整个三维坐标系的建立、所有法线的计算、三角形内外的判定,全建立在叉乘上。
定义:大小和方向要分开记
叉乘的定义分两半,这一点很重要,因为很多教材只写了一半。
大小:
‖a × b‖ = ‖a‖‖b‖ sin θ
注意这里是 sin(点乘是 cos)。课程特意指出“这里写的并不完善——它只定义了大小,方向没有写出来”。
方向: 垂直于 a 和 b 两者。而“垂直”有两个方向(上下),要选一个,靠右手螺旋定则。
右手螺旋定则
伸出右手,四指指向从 a 转到 b 的旋转方向,拇指指的方向就是 a × b 的方向。
课程形容这个动作“好像点赞那么一个样子”。
于是立刻有一个推论:把顺序换过来,b × a,四指要从 b 转到 a——反着转,拇指就朝下。所以:
a × b = −(b × a)
叉乘没有交换律,交换要加负号。 这是叉乘和点乘最大的性质差别,也是最容易在代码里出错的地方。
三条必须记住的性质
“自叉为零”有个细节值得强调:结果是零向量,不是数字 0。 课程专门说了这一点。区别在于类型:a × a 还是一个三维向量,只是三个分量都是 0;它不能和标量 0 混用。
为什么自叉为零?回到大小公式:a 和自己的夹角是 0,sin 0 = 0,长度为零。零长度的向量没有方向,所以“垂直于自己”这个要求随便怎么满足都行——结果是退化的零向量。
坐标形式
把 a、b 的分量分别记作 (a₁, a₂, a₃) 和 (b₁, b₂, b₃):
a × b = (a₂b₃ − a₃b₂, a₃b₁ − a₁b₃, a₁b₂ − a₂b₁)
这个公式建议这样记,不容易错:每个分量都是“自己这一维以外的两个维度”做交叉相乘再相减,顺序是“前者配后者的后一项、减后者的前一项”。
检查一下第一条分量 a₂b₃ − a₃b₂:算 x 分量时,用 y 和 z 这两个维度——先 a 的 y 分量乘 b 的 z 分量,减去 a 的 z 分量乘 b 的 y 分量。第二、三条按 x→y→z 循环轮换即可。
(数学上这是三阶行列式按第一行展开的结果。如果你熟悉行列式,那个记法更不容易错;如果不熟,就用上面的循环规则。)
右手坐标系:一个必须统一的约定
把上面的公式用在三个轴向量上,会得到:
x̂ × ŷ = ẑ,ŷ × ẑ = x̂,ẑ × x̂ = ŷ
以及反向的三条都带负号。课程说“只要能记住头一个 x̂ × ŷ = ẑ,剩下五个按循环推就行”。
定义:如果一个坐标系满足 x̂ × ŷ = ẑ,就叫右手坐标系。
这门课全程使用右手系。
这里有一处课程说法需要澄清。课程说“如果大家会接触到 OpenGL 或者别的 API,他们会假设我们用左手坐标系”。这个说法不准确:OpenGL 默认就是右手坐标系,DirectX 传统上是左手坐标系。 两个 API 恰好在这一点上相反,这曾经是跨平台图形移植的一个经典麻烦。
换到具体 API 或引擎时,第一件事是确认它的手性。确认方法很简单:看它的投影矩阵是把 z 映到 [−1, 1](OpenGL 传统)还是 [0, 1](DirectX、Vulkan),或者干脆查文档里相机的朝向约定(OpenGL 相机朝 −z,DirectX 朝 +z)。手性搞反的症状是:模型左右镜像、深度测试全反、背面剔除剔掉正面。这三个症状出现任何一个,先怀疑手性。
一个几何事实(课程没讲,但很有用)
‖a × b‖ = ‖a‖‖b‖ sin θ——这个式子的几何意义是:以 a 和 b 为两条边的平行四边形的面积。
因为平行四边形面积 = 底 × 高 = ‖a‖ × (‖b‖ sin θ)。
这条事实有两个直接用途:
- 三角形面积
= ½‖a × b‖(a、b 是两条边)。求面积不用海伦公式。 - 法线方向 + 面积大小的组合
:在网格处理里,把每个三角形的叉积除以 2 再累加,得到的是整个曲面的面积加权法线。这个量在计算体积、判断网格朝向、甚至做碰撞检测时都要用。
代码
Vector3f a(1, 2, 3), b(4, 5, 6);Vector3f c = a.cross(b); // Eigen: 叉乘float area = c.norm() * 0.5f; // 平行四边形面积的一半Eigen 里是 .cross(),.dot() 是点乘。两个名字很像,别写错——写错了编译器通常不报错,因为类型恰好都能过。
验收
- 能手算。
a = (1,0,0),b = (0,1,0)。求 a × b 和 b × a。 - 能验证垂直。
对上一题的结果,用点乘验证它确实垂直于 a 和 b。 - 故意制造一个错误。
写三角形法线时用 cross(v1 − v2, v1 − v3) 而不是 cross(v1 − v2, v2 − v3)。法线会怎样?——可能翻向反面(取决于三个点的绕序)。症状是:从一侧看模型是亮的,从另一侧看是全黑,而且光照随视角突变。法线方向由叉乘的操作数顺序决定,改一个下标就翻了。 这个 bug 在渲染器里极其常见。
合上页面,回答这三个问题
叉乘为什么没有交换律,而点乘有? x̂ × ŷ = ẑ 这个等式为什么能用来“定义”右手系? - (超出本节)
为什么 ‖a × b‖ 等于平行四边形面积?用 sin 的几何意义解释。
第 3 题的参考答案:设 a 为底边,长度 ‖a‖。以 a 为基准,b 在垂直于 a 方向上的“高度”是 ‖b‖ sin θ——因为 b 分解后,垂直分量正好是 ‖b‖ sin θ(平行分量是 ‖b‖ cos θ)。底乘高就是面积。所以叉乘的大小是“垂直分量的长度乘底边长度”,这也解释了为什么两向量平行时面积(和叉乘)为零——没有高度。
值得回看画面的两处
这一篇的技术内容基本能用文字替代,两处例外:
- 06 节 26:11–26:33
——“圆被虚线切成两半”的示意图。课程用它说明哪些向量算“向前”。我用表格描述了同一个划分,但哪半边是上半圆这种空间关系依赖画面。如果你对方向没把握,自己画一个单位圆、画一条竖直直径、标一下正负区,十秒就清楚。 - 07 节 31:57–33:10
——右手定则。课程是伸手比划的,“从 a 转到 b 时拇指朝上”这句话的方向判断依赖屏幕上箭头的空间位置。这一处建议真的看一眼,或者自己用手比一次,比读十遍公式快。
这一篇到此为止
三节合起来,你手上应该有两件工具:一件把向量拆开(投影),一件把两个方向合成第三个方向(叉乘)。前者的产物是“沿法线多少、垂直法线多少”,后者的产物是“法线本身”。
下篇从叉乘的用法开始:判左右、判内外。那是第五讲三角形光栅化的数学前提——换句话说,没有那两页,你第五讲写不出一行代码。















































































