
做三维几何计算这几年我发现自己最常翻车的地方往往不是矩阵乘法和四元数反而是最基础的“向量投影”。尤其在处理碰撞、法线贴图这类任务时经常要把一个向量按某个平面拆成“贴着面的分量”和“垂直面的分量”也就是俗称的向量在面上投影。这个概念在二维里人人都会一旦上升到三维平面很多人就开始套公式、背结论结果符号错了、方向反了、平面过不过原点也没想清楚。这篇文章我想把投影这件事从头到尾拆透既讲清楚标量投影和向量投影的区别也把三维空间中向量在平面上的投影推导到可以直接照着算的程度最后给几个我在实际项目里反复踩过的坑。如果你也在做图形学、物理仿真或者任何跟三维向量打交道的事这篇内容应该能帮你省下不少试错时间。1. 先分清三种投影别再一上来就套点积公式很多教程一上来就写proj_b(a) (a·b / |b|²) b看起来简洁但读者根本不知道自己算出来的到底是个长度还是个向量。我建议在动笔之前先搞清楚你面对的是哪一种投影。1.1 标量投影和向量投影是两码事给定两个非零向量a和b。a在b方向上的标量投影也叫分量长度定义是comp_b(a) |a| cosθ (a·b) / |b|这里 θ 是两个向量的夹角。注意这个结果是带符号的如果a和b的夹角小于 90 度它是正数大于 90 度它是负数。它表示的只是“长度”不携带方向信息。而向量投影则是在这个标量基础上再乘上b的单位方向向量proj_b(a) comp_b(a) * (b / |b|) (a·b / |b|²) b很多教材只说“投影”不说清楚是标量还是向量。我见过不少同学在代码里写着写着就把(a·b)/|b|当成一个向量去用结果整个方向全错了。比如a (3, 4)b (1, 0)标量投影是 3但向量投影是(3, 0)。一个是数一个是向量性质完全不同。1.2 面上投影不是“投影到某个方向”而是“丢掉法向分量”当我们说“向量在面上的投影”这里的“面”是一个二维平面不是一个方向。三维空间里一个平面由它的法向量唯一确定方向。向量a相对于平面 π可以唯一分解成两部分a a_⊥ a_∥其中a_⊥垂直于平面方向沿法向量a_∥平行于平面也就是贴着面走的分量。面上投影指的就是这个a_∥。这个理解很关键。二维中的投影是把向量压缩到一条直线上三维中的面上投影是把向量压缩到一个平面上。它不是一个简单的点积就能直接算出来的而是需要“先算垂直分量再做减法”。1.3 这篇文章里统一用的记号为了避免后面推导和算例看着乱先约定一下记号。平面我用 π 表示法向量用n表示单位法向量写成n_hat。向量a在平面 π 上的投影写作a_face。默认情况下讨论的是过原点的平面如果平面不过原点在第 4.4 节我会专门说明该怎么处理。这个约定在做工程计算时很重要因为不同书籍和代码库对“投影”的定义可能略有出入但底层数学是一致的。2. 二维投影从夹角余弦到底层直觉三维面投影的公式虽然能直接背但如果不理解二维投影的几何直觉三维照样出错。所以这里先用二维把点积和投影的关系彻底讲清楚。2.1 点积的本质是一个向量对另一个向量方向的贡献点积的定义是a·b |a||b|cosθ。这个cosθ单独拎出来看没什么感觉但把它写成这样就好懂了a·b |b| * (|a| cosθ)括号里的|a| cosθ就是向量a在向量b方向上的投影长度。换句话说点积天然就是在算“一个向量在另一个向量方向上的贡献有多大”只不过结果还乘上了|b|。如果把b先变成单位向量e_b那么a·e_b就直接是投影长度不需要再除以|b|。这两个式子看起来只是移项但理解角度完全不同。我自己的感觉是一旦接受了“点积本质是投影长度乘模长”这个视角后面所有投影公式都不需要死记。2.2 一个手算例子把分解过程看清举个例子a (4, 3)b (1, 2)。先算|b| √(1² 2²) √5。标量投影comp (a·b) / |b| (4×1 3×2) / √5 10/√5 2√5 ≈ 4.472向量投影proj_b(a) comp * (b/|b|) 2√5 * (1/√5, 2/√5) (2, 4)也就是a在b方向上的投影向量是(2, 4)。剩下的残差a - proj (4, 3) - (2, 4) (2, -1)验证一下残差和b的点积是2×1 (-1)×2 0说明残差确实垂直于b。这正好对应了“任意向量可分解成平行方向和垂直方向两部分”而投影就是平行部分残差就是垂直部分。2.3 为什么实际代码里都优先用单位向量工程代码里几乎不会直接拿原始向量去套(a·b / |b|²) b这个公式而是先把b单位化再算proj (a·e_b) * e_b这样只需要一次点积和一次标量乘法代码简单也少一次除法。更重要的是单位向量在之后涉及方向判断时更安全。比如你要判断投影方向和参照方向是否同向直接看a·e_b的符号就行。实际写代码时有一个细节如果b模长接近零单位化会放大浮点误差甚至产生 NaN。我一般会在归一化之前先判断模长是否大于某个阈值比如1e-8而不是直接开根号。3. 平面投影的核心思路让法向量当“剪刀”进入三维之后场景变了平面不再是一个方向而是一个二维子空间。但处理思路反而更简单既然平面由法向量定义那垂直分量的计算就完全沿用向量投影公式把法向量当成“被投影方向”即可。3.1 核心公式一句话a_face a - (a·n_hat) n_hat当法向量已经单位化即n_hat的模长为 1 时向量a在平面 π 上的投影向量就是a_face a - (a·n_hat) n_hat这个公式背后的图像非常清楚先算出a在法线方向上有多少分量把它剪掉剩下的自然就贴在平面上了。为了确保这一点我可以验证a_face · n_hat a·n_hat - (a·n_hat)(n_hat·n_hat) a·n_hat - a·n_hat 0这说明a_face与法向量垂直因此它一定在平面内。公式不需要记记住“用减法剪掉法向分量”就够了。3.2 法向量没单位化怎么办实际项目中平面的法向量经常不是单位向量。比如平面方程Ax By Cz D直接给出的法向量n (A, B, C)模长一般不等于 1。这个时候公式要改成a_face a - (a·n / |n|²) n为什么是除以|n|²而不是|n|因为a在n方向的分量长度 (a·n) / |n|把这个长度乘上单位法向量n / |n|就得到法向投影向量a_n ((a·n) / |n|) * (n / |n|) (a·n / |n|²) n我看到过很多初学者在这里卡住背公式的时候记住了(a·n/|n|²) n但推导一遍以后就再也不会把|n|²写漏了。3.3 为什么不直接套夹角公式有人会问三维里向量和平面也有夹角能不能像二维一样用 cos 来算理论上当然可以设a与法向量n的夹角为 θ则a在法线方向的投影长度是|a| cosθ再用这个长度乘单位法向量也能得到a_n。但问题是求 cosθ 本身就得算(a·n) / (|a||n|)绕了一圈最后还是回到点积。直接用减法公式不仅少算一次|a|还避免了角度相关的符号判断。所以在实际工程里我几乎从来不用角度形式。4. 一个完整算例从解析式到几何验证公式讲完了不上手算一遍等于没学。这里我挑一个有代表性的例子手写完整推导过程每一步都说明为什么这样做。4.1 给出平面和向量先把法向量单位化假设平面方程为x 2y 2z 5法向量是n (1, 2, 2)模长|n| √(1 4 4) 3。单位法向量n_hat (1/3, 2/3, 2/3)现在取一个向量a (6, 1, -2)目标求a在这个平面上的投影向量。4.2 按减法公式手算分解按a_face a - (a·n_hat) n_hat来。先算点积a·n_hat 6×(1/3) 1×(2/3) (-2)×(2/3) 2 2/3 - 4/3 4/3所以法向分量的投影向量a_n (4/3) × n_hat (4/9, 8/9, 8/9)接着做减法a_face (6, 1, -2) - (4/9, 8/9, 8/9) (50/9, 1/9, -26/9)这个(50/9, 1/9, -26/9)就是a在平面x 2y 2z 5方向上的面投影向量。注意这里说的是“该平面方向”具体位置差异在第 4.4 节说明。4.3 从标量投影的角度验证同一结果用非单位法向量公式再验证一遍保证没有算错。n (1, 2, 2)|n|² 9a·n 6 2 - 4 4。法向投影a_n (a·n / |n|²) n (4/9)(1, 2, 2) (4/9, 8/9, 8/9)看到没有和用单位法向量算出来的完全一致。这正好说明两个公式只是表面不同本质是同一件事。再验证a_face确实和平面平行a_face · n (50/9)×1 (1/9)×2 (-26/9)×2 50/9 2/9 - 52/9 0点积为零说明a_face垂直于法向量也就是平行于平面。同时a_n a_face (4/9, 8/9, 8/9) (50/9, 1/9, -26/9) (54/9, 9/9, -18/9) (6, 1, -2) a分解和还原都成立计算没有矛盾。4.4 平面不过原点会产生什么不同这是很多教程忽略的坑。上面我们讨论的是“自由向量”的投影向量本身没有起点所以只是把法向分量减掉。但如果你讨论的是“从原点出发的向量其终点在平面 π 上的正交投影”那就需要额外处理平面位置。为什么因为a_face是从原点出发的箭头它的终点在平面x 2y 2z 0上而不是在x 2y 2z 5上。如果想要投影点落在给定平面x 2y 2z 5上正确做法是先取平面上任意一点比如直接让x 5, y 0, z 0得到O_p (5, 0, 0)。把待投影向量变成a a - O_p对a做面投影得到a_face最后再加上O_p。口诀就是先平移再投影最后平移回来。很多做碰撞检测的人在这里栽过跟头因为把一个向量投影到“平面所在位置”和“平面所在方向”是两种不同的需求。5. 实践中容易翻车的几个细节公式本身不难难的是在真实环境里不出错。下面这几个问题我全踩过而且都是在代码已经跑起来之后才发现的。5.1 法向量没单位化就套用 a - (a·n)n最经典的翻车现场。很多图形学入门者看到公式a_face a - (a·n)n以为n是随便给的方向向量结果算出来的投影既不在面上也不满足垂直条件。我拿上面的例子演示一下错误。n (1, 2, 2)a (6, 1, -2)如果错误计算a a - (a·n) n (6, 1, -2) - 4×(1, 2, 2) (2, -7, -10)这个向量和n的点积是2 - 14 - 20 -32根本不等于零说明它压根不平行于平面。原因就是a·n是“长度 × |n|”的量而不是真正的法向投影向量必须除以|n|²或者提前单位化。我现在的习惯是在函数入口统一做单位化内部永远只处理单位法向量这样最不容易出错。5.2 零向量和近乎零的方向不能硬算如果a是零向量所有点积都是零投影结果也是零向量这个没问题。但麻烦的是“长度接近零但不等于零”的情况。比如物理仿真里某一帧的碰撞速度非常小直接做归一化或投影可能产生数值抖动。我的做法是先判断向量模长是否小于某个阈值比如1e-6如果小于阈值就直接当作零向量处理不进入投影流程。这一点对于实时物理引擎尤其重要因为 NaN 会像瘟疫一样在帧之间传播。5.3 平行和垂直这两个极端情况当向量a已经平行于平面时a·n 0所以a_face a。这是好情况说明投影不应该改变原向量。当向量a严格垂直于平面时a_face 0。这也是对的但要注意在工程里它可能引发后续逻辑问题比如你基于投影向量去计算方向结果得到零向量再归一化就崩了。所以判断完投影结果后最好检查一下模长别默认投影永远是“非零向量”。5.4 标量投影的符号代表了物理方向标量投影(a·n_hat)是有符号的。在很多物理计算里法向量n被约定指向某个特定侧比如平面外侧。此时(a·n_hat)为正说明a有朝外的分量为负说明a指向平面内侧。这个符号直接决定了法向分量的方向也决定碰撞反弹到底往哪走。我见过不少人只关心投影后的长度把符号丢掉最后得到的结果在物理上完全反了。记住投影不只给长度也给方向符号就是方向的一部分。6. 面投影能用在哪儿几个我实际遇到过的场景很多人学投影只是应付考试但我真正理解面投影的威力是在几个具体的工程场景里。这里挑三个有代表性的给大家一点直观感受。6.1 碰撞速度分解与反弹物理仿真里球砸到一个斜面上计算反弹速度时要把入射速度分解成法向和切向两部分。设碰撞点处的单位法向量为n_hat入射速度为v则v_n (v·n_hat) n_hatv_t v - v_n法向分量通常乘以一个恢复系数e0 到 1 之间切向分量要考虑摩擦力。最后重组反弹速度v -e * v_n (1 - μ) * v_t这里面的v_t就是v在碰撞平面上的投影向量。如果不理解面上投影你就不知道怎么把摩擦和弹性分开处理。这个场景是我见过面上投影最直接的应用。6.2 法线贴图与切线空间重建在法线贴图里法线扰动向量通常定义在切线空间。切线空间由一组基向量构成T沿 U 方向、B沿 V 方向和N原法线方向。从切线空间变换到世界空间时常常需要把一个向量投影到T-B平面上再重新组合。比如要重建法线时已知一个向量h的 x、y 分量想得到它在平面上的完整向量就要先把h投影到切平面再做归一化。这个操作的本质和面上投影完全一致把三维向量减掉沿N方向的分量剩下的就是切平面里的向量。6.3 高维子空间投影与最小二乘把面投影再往前走一步就是高维空间的子空间投影。当你把向量投影到一个由多个基向量张成的子空间时比如线性回归里把因变量y投影到自变量矩阵A的列空间得到的就是最小二乘解。投影在这里的意义是误差向量必须和子空间正交这是“最优解”的几何解释。平面投影是最简单的二维子空间投影理解了它再看高维投影矩阵A(A^T A)^{-1}A^T就不会觉得那么抽象了。6.4 一个简单自查模板最后送一个我常用的自查工具写一个小函数专门做面投影并且用点积验证结果。代码层面非常简单但能省掉大量肉眼查错的时间。import numpy as np def project_to_plane(v, n, normalizedFalse): if normalized: vn (np.dot(v, n)) * n else: vn (np.dot(v, n) / np.dot(n, n)) * n v_face v - vn # 断言投影结果应与法向量正交 assert abs(np.dot(v_face, n)) 1e-6, projection not orthogonal to normal return v_face # 示例 a np.array([6.0, 1.0, -2.0]) n np.array([1.0, 2.0, 2.0]) print(project_to_plane(a, n))我自己在用这个函数时通常会把n预先单位化然后normalizedTrue这能减少一次除法也让断言更严格。你可以根据自己的场景封装成静态方法或者工具库核心逻辑永远只有三行点积、乘单位向量、减法。最后再分享一点个人体会如果你只能在所有投影公式里记住一个那一定是“面上投影 原向量 - 法向量方向分量”。这个式子比任何夹角公式都直观也最不容易写错。每次写代码之前先在草稿纸上把法向量画出来把“剪掉”那个动作在脑子里过一遍再复杂的平面投影也不会出大问题。