一个点积,为什么能回答这么多游戏问题?
从夹角、前后判断和视野检测,到 Lambert 光照、斜坡、碰撞响应与向量投影:理解游戏开发中最常用也最值得理解的数学工具之一——点积。
如果要让我选一个:
游戏开发里投入产出比最高的数学公式
我大概会选择点积:
它的计算甚至简单得有点不起眼。
二维:
那么:
三维也不过是:
几个乘法,再加起来。
就这么一个公式,却可以回答游戏里的大量问题:
- 敌人在我的前面还是后面?
- 玩家有没有看着一个物体?
- NPC 是否进入视野范围?
- 这个表面朝向光源还是背对光源?
- 地面的坡度有多大?
- 速度在某个方向上有多快?
- 一个力有多少真正作用在这个方向上?
- 两个方向到底有多相似?
- 如何把速度沿着墙面分解?
- 为什么最基础的光照公式里也有一个点积?
甚至可以说:
只要游戏里出现了“方向之间是什么关系?”这个问题,点积往往就在附近。
而它最漂亮的地方,是这些看似完全不同的问题,实际上都可以归结成同一个数学问题:
两个向量到底有多朝向同一个方向?
点积最核心的直觉
点积可以看作一个“方向相似度”。
对于两个单位向量:
所以:
1:完全同方向0:互相垂直-1:完全反方向
大量游戏逻辑,本质上就是在利用这三个状态之间的连续变化。
1从“两个箭头”开始
先考虑两个向量:
每个向量都可以理解成一个箭头:
我们很自然会问:
它们之间差多少度?
数学上有:
这大概是理解点积最重要的一条公式。
这里:
- 是向量 的长度
- 是向量 的长度
- 是两个向量之间的夹角
如果两个向量已经归一化:
那么公式瞬间变成:
于是点积直接变成了夹角的余弦值。
这也是游戏开发中最常见的使用方式。
2为什么 dot > 0 就代表“在前面”?
假设角色朝向:
从角色指向敌人的方向:
可以计算:
如果两个向量归一化:
Vector3 direction = (target.position - transform.position).normalized;
float dot = Vector3.Dot(transform.forward, direction);那么:
dot = 1 完全在正前方
dot > 0 在前方半球
dot = 0 正好在侧面
dot < 0 在后方半球
dot = -1 完全在正后方为什么?
因为:
而:
恰好对应:
也就是前方 180°。
所以:
if (Vector3.Dot(transform.forward, direction) > 0)
{
// Target is in front of us.
}背后的含义其实是:
我根本不需要计算真正的角度。
我只需要知道余弦是正还是负。
这就是游戏数学里非常常见的思想:
不要计算超过问题真正需要的信息。
3判断前后时,甚至不需要 Acos
假设你只是想知道:
敌人是不是在我前面?
很多初学者可能会这样写:
float angle = Vector3.Angle(transform.forward, direction);
if (angle < 90f)
{
// Front
}这当然可以。
但如果只是为了前后判断,实际上:
Vector3.Dot(transform.forward, direction) > 0就够了。
因为你真正关心的根本不是:
43.72°而只是:
前面 / 后面点积直接给出了这个信息。
4从“前后判断”到敌人的视野
现在把问题稍微升级:
NPC 只能看到自己正前方 90° 范围里的玩家。
如果 NPC 的完整视野角是:
那么从中心线向左右各:
于是判断条件是:
当然可以真的求:
但是没有必要。
因为:
所以只需要:
float dot = Vector3.Dot(forward, direction);
if (dot > Mathf.Cos(45f * Mathf.Deg2Rad))
{
// Inside FOV.
}也就是:
这样一个非常常见的 AI 视野问题,最终仍然只是:
一次点积 + 一次比较。
而且:
Mathf.Cos(halfFov)甚至可以预先算好。
于是每个目标只需要计算一次点积。
5一个很有用的规律:阈值越大,视野越窄
对于单位向量:
因为余弦在:
之间是不断下降的。
所以:
| Dot 阈值 | 最大夹角 |
|---|---|
1.0 | |
0.866 | |
0.707 | |
0.5 | |
0.0 | |
-1.0 |
因此:
dot > 0.9表示两个方向非常接近。
而:
dot > 0.1则宽松得多。
这个规律在很多系统里都非常好用:
- Aim Assist
- Lock-On
- AI Vision
- Interaction
- Auto Targeting
- Camera
- Navigation
6点积其实是在问:“一个向量在另一个方向上有多少?”
这是理解点积更深的一层。
假设:
是角色速度。
而:
是角色的前方向,并且:
那么:
表示什么?
答案是:
速度在前方向上的分量。
例如:
虽然角色可能斜着运动,
但:
告诉我们:
真正沿 Forward 方向移动得有多快。
例如:
float forwardSpeed =
Vector3.Dot(velocity, transform.forward);如果:
forwardSpeed > 0角色正在往前运动。
如果:
forwardSpeed < 0角色正在倒退。
如果:
forwardSpeed = 0角色的速度完全是横向的。
这在车辆、角色控制和动画系统中特别常见。
比如:
float forwardVelocity = Vector3.Dot(velocity, forward);
float lateralVelocity = Vector3.Dot(velocity, right);瞬间就可以把世界空间速度:
velocity分解成:
Forward Speed
Lateral Speed于是车辆就可以知道:
我到底是在正常前进,还是正在侧滑?
7点积和投影其实是一回事
假设我们想把:
投影到单位向量:
上。
投影长度就是:
而完整投影向量则是:
如果 没有归一化:
这件事非常重要。
因为它意味着点积实际上在干:
把一个向量拆开,看它有多少属于另一个方向。
例如:
a
↗
/|
/ |
/ |
─────●───●────────→ b
projection这马上就能解释大量游戏物理问题。
8为什么角色撞墙以后还能沿墙滑动?
假设角色速度:
墙面法线:
其中 是单位向量。
速度有一部分指向墙:
那么我们把这一部分从速度里减掉:
得到的就是:
沿着墙面的速度。
代码:
Vector3 slideVelocity =
velocity -
Vector3.Dot(velocity, normal) * normal;或者直接:
Vector3.ProjectOnPlane(velocity, normal);于是一个看起来像“碰撞系统”的问题,核心数学其实是:
找到速度中朝向墙面的部分,把它删掉。
而找这一部分的工具:
还是点积。
9反弹同样来自点积
如果不是沿墙滑动,而是让速度发生理想镜面反射:
为什么这里变成了:
?
因为第一次减:
只是把法线方向上的速度减到:
而我们想让它继续到相反方向。
所以还要再减一次。
于是 Unity 里的:
Vector3.Reflect(direction, normal);其背后的核心仍然是:
点积。
10地面到底有多陡?
现在来看角色控制器里另一个非常常见的问题。
假设地面法线是:
世界 Up 是:
那么:
这里的:
其实就是坡面的倾斜程度。
完全平坦的地面:
于是:
垂直墙壁:
倒挂的表面:
于是我们可以直接判断:
float groundDot = Vector3.Dot(hit.normal, Vector3.up);
if (groundDot > minGroundDot)
{
// Walkable ground.
}比如最大可行走坡度是:
那么:
float minGroundDot = Mathf.Cos(45f * Mathf.Deg2Rad);也就是大约:
于是:
if (Vector3.Dot(hit.normal, Vector3.up) > 0.707f)
{
// Walkable
}甚至根本没有必要真的计算:
slopeAngle11光照为什么也是点积?
如果你写过最基础的 Shader,应该见过这样的东西:
float NdotL = max(0, dot(normal, lightDirection));这就是最经典的:
Lambert Diffuse Lighting
其核心是:
为什么?
假设光正对着表面:
↓ Light
──────────── Surface
↑ N那么:
最亮。
当光越来越倾斜:
↙ Light
──────────── Surface
↑ N夹角越来越大:
越来越小。
于是单位面积实际接收到的能量也越来越少。
当光线与表面完全平行:
所以:
没有漫反射光。
背面则:
因此:
max(0, dot(N, L))把负数截断。
一个非常基础的实时光照模型,就这样从点积里长出来了。
一个非常漂亮的统一
AI 在问:
目标与我的朝向有多一致?
角色控制器在问:
地面法线与世界 Up 有多一致?
车辆在问:
速度与车头方向有多一致?
Shader 在问:
表面法线与光照方向有多一致?
数学上的答案全部是:
12为什么两个垂直向量的点积是 0?
从几何公式看:
如果:
那么:
所以:
这给了我们一个非常方便的判断:
两个非零向量点积为 0,则互相垂直。
这在构造坐标系、检测正交关系、几何算法中非常常见。
例如:
bool approximatelyPerpendicular =
Mathf.Abs(Vector3.Dot(a.normalized, b.normalized)) < 0.001f;13为什么两个相反方向的向量点积是负数?
如果:
那么:
所以:
这就是为什么点积天然能够区分:
同一个半球和:
相反半球这也是:
dot > 0判断前后如此好用的原因。
14点积到底为什么会等于余弦?
到这里可能会产生一个更根本的问题:
为什么几个坐标相乘再相加,居然能得到两个方向之间的夹角?
这个关系并不是凭空出现的。
考虑三角形:
A ●
\
\
\
● B
O ●两个向量:
它们的差:
根据余弦定理:
另一方面:
展开:
而:
于是:
和余弦定理比较:
因此:
点积的“代数定义”和“几何定义”其实是同一件事情的两种描述。
这也是线性代数中非常漂亮的一点:
坐标运算和几何关系被一个公式连接了起来。
15点积自己其实不会告诉你“左还是右”
这里有一个非常常见的误区。
假设目标分别位于:
A
↖
──────→ Forward
↙
B如果 A 和 B 与 Forward 的夹角大小相同:
那么:
点积只知道:
夹角有多大。
但它不知道:
目标是在左边还是右边。
因为:
左右信息在余弦里消失了。
所以:
点积擅长回答“有多对齐”。
如果还需要判断左右,就通常要结合:
叉积 Cross Product。
例如:
Vector3 cross = Vector3.Cross(forward, direction);
float side = Vector3.Dot(cross, up);这也正好可以成为下一篇文章的话题:
点积告诉你“有多朝向”,叉积告诉你“朝哪边偏”。
16Dot Product 最大的坑:忘记向量长度
如果:
和:
不是单位向量,那么:
并不只是:
而是:
例如:
Vector3 a = new Vector3(10, 0, 0);
Vector3 b = new Vector3(10, 0, 0);
float dot = Vector3.Dot(a, b);结果不是:
1而是:
因为:
所以如果你的目的真的是比较:
方向
通常应该:
float dot = Vector3.Dot(a.normalized, b.normalized);不过也不能机械地认为:
Dot 前一定要 Normalize。
因为有时候向量长度本身就是有意义的。
比如:
用来求 Forward Speed 时,
我们恰恰希望保留:
只需要保证:
是单位向量。
因此更准确的规则是:
先理解公式里的向量长度代表什么,再决定是否 Normalize。
17如果真的要求角度,记得 Clamp
如果确实需要从点积恢复角度:
对于单位向量,理论上:
但浮点数计算可能出现:
1.0000001这样的微小误差。
而:
在实数范围并没有定义。
因此比较稳妥的写法是:
float dot = Vector3.Dot(a.normalized, b.normalized);
dot = Mathf.Clamp(dot, -1f, 1f);
float angle = Mathf.Acos(dot) * Mathf.Rad2Deg;不过再次强调:
如果只是做范围判断,通常根本不要算 Acos。
例如不要:
if (Mathf.Acos(dot) < angle)而应该比较:
if (dot > Mathf.Cos(angle))因为真正的问题只是一个阈值判断。
18还有一个容易忽略的问题:坐标空间必须一致
考虑:
Vector3.Dot(a, b)这个公式隐含了一个前提:
两个向量必须处在同一个坐标空间里。
例如:
a = Local Space
b = World Space然后直接 Dot,
得到的数字通常没有你以为的意义。
比如:
transform.forward通常表示世界空间方向。
而某个 Mesh Normal 可能还在:
Object SpaceShader 中又可能存在:
World Space
View Space
Tangent Space如果要做点积,就必须先把它们转换到同一个空间。
这也是为什么游戏图形学代码里会不断出现:
Object → World
World → View
Tangent → World很多“Shader 怎么看起来不对”的问题,最后并不是 Dot 写错了。
而是:
你拿两个不同世界里的方向做了点积。
19点积其实是在做“相关性”
如果把视角再拉高一点,会发现点积还有一种很有意思的理解:
衡量两个对象有多相似。
例如单位向量:
完全一样 → 1
完全无关/正交 → 0
完全相反 → -1这种思想并不只存在于 3D 图形学。
在更高维空间里依然成立。
一个向量可以不再是:
而是:
点积仍然是:
于是它开始连接到:
- Signal Processing
- Machine Learning
- Neural Networks
- Embeddings
- Cosine Similarity
比如机器学习中经常使用的:
Cosine Similarity
其实就是:
如果已经 Normalize:
突然之间:
游戏里判断“敌人是不是朝着我”
和:
AI 判断“两段文本语义是否相似”
在数学上居然出现了同一个操作。
这也是线性代数最有魅力的地方之一。
20一个公式串起半个 3D 游戏世界
现在重新回头看这些问题。
AI:
Vector3.Dot(forward, directionToPlayer)判断目标是不是在视野里。
角色控制器:
Vector3.Dot(groundNormal, Vector3.up)判断地面是否足够平缓。
车辆:
Vector3.Dot(velocity, transform.forward)求前进速度。
侧滑:
Vector3.Dot(velocity, transform.right)求横向速度。
碰撞:
Vector3.Dot(velocity, normal)求速度撞向表面的程度。
光照:
dot(normal, lightDirection)求表面接受多少光。
它们看起来完全是不同系统:
但它们真正的问题全部可以重新描述成:
一个方向,在另一个方向上有多少?
或者:
两个方向到底有多一致?
而数学恰好给了我们一个极其便宜的操作:
21为什么点积值得真正理解,而不只是记住 API?
当然,你完全可以只记住:
Vector3.Dot(a, b);然后查文档:
> 0 same-ish direction
< 0 opposite-ish direction这已经足够写很多游戏逻辑。
但真正理解:
以后,你会开始主动发现很多问题其实都可以用 Dot 解决。
当你看到:
判断坡度
你会想到:
Normal · Up看到:
判断敌人是否在准星附近
你会想到:
Forward · Direction看到:
求车辆横向滑动速度
你会想到:
Velocity · Right看到:
删除撞向墙面的速度
你会想到:
Velocity · Normal这时候数学就不再是一堆需要背下来的公式。
它变成了一种:
描述问题的语言。
22最后
点积大概是一个非常典型的游戏数学例子。
它的代码可能只有:
float dot = Vector3.Dot(a, b);底层甚至不过:
float dot =
a.x * b.x +
a.y * b.y +
a.z * b.z;但背后却连接着:
进一步连接到:
- 夹角
- 投影
- 正交
- 坐标分解
- 光照
- 碰撞
- AI
- 角色控制
- 向量空间
一个看起来只有三个乘法、两个加法的公式,
却可以回答半个 3D 游戏世界里的问题。
也许这正是游戏数学最有意思的地方:
真正重要的数学往往不是复杂,而是足够基础,以至于可以反复出现在完全不同的问题里。
当你真正理解它以后,你开始看到的不再是:
“这里应该调用哪个 API?”
而是:
这个游戏问题,本质上到底是什么几何关系?
CONTINUE EXPLORING