Elytra
Blog数学

一个点积,为什么能回答这么多游戏问题?

从夹角、前后判断和视野检测,到 Lambert 光照、斜坡、碰撞响应与向量投影:理解游戏开发中最常用也最值得理解的数学工具之一——点积。

如果要让我选一个:

游戏开发里投入产出比最高的数学公式

我大概会选择点积:

a⋅b\mathbf a\cdot\mathbf b

它的计算甚至简单得有点不起眼。

二维:

a=(ax,ay),b=(bx,by)\mathbf a=(a_x,a_y),\qquad \mathbf b=(b_x,b_y)

那么:

a⋅b=axbx+ayby\mathbf a\cdot\mathbf b = a_xb_x+a_yb_y

三维也不过是:

a⋅b=axbx+ayby+azbz\mathbf a\cdot\mathbf b = a_xb_x+a_yb_y+a_zb_z

几个乘法,再加起来。

就这么一个公式,却可以回答游戏里的大量问题:

  • 敌人在我的前面还是后面?
  • 玩家有没有看着一个物体?
  • NPC 是否进入视野范围?
  • 这个表面朝向光源还是背对光源?
  • 地面的坡度有多大?
  • 速度在某个方向上有多快?
  • 一个力有多少真正作用在这个方向上?
  • 两个方向到底有多相似?
  • 如何把速度沿着墙面分解?
  • 为什么最基础的光照公式里也有一个点积?

甚至可以说:

只要游戏里出现了“方向之间是什么关系?”这个问题,点积往往就在附近。

而它最漂亮的地方,是这些看似完全不同的问题,实际上都可以归结成同一个数学问题:

两个向量到底有多朝向同一个方向?

点积最核心的直觉

点积可以看作一个“方向相似度”。

对于两个单位向量:

a⋅b=cos⁡θ\mathbf a\cdot\mathbf b=\cos\theta

所以:

  • 1:完全同方向
  • 0:互相垂直
  • -1:完全反方向

大量游戏逻辑,本质上就是在利用这三个状态之间的连续变化。

1从“两个箭头”开始

先考虑两个向量:

a,b\mathbf a,\mathbf b

每个向量都可以理解成一个箭头:

拖动 a、b:上方读数为真实点积 a·b 与 cos θ(只与方向有关)。

我们很自然会问:

它们之间差多少度?

数学上有:

a⋅b=∣a∣∣b∣cos⁡θ\mathbf a\cdot\mathbf b = |\mathbf a||\mathbf b|\cos\theta

这大概是理解点积最重要的一条公式。

这里:

  • ∣a∣|\mathbf a| 是向量 a\mathbf a 的长度
  • ∣b∣|\mathbf b| 是向量 b\mathbf b 的长度
  • θ\theta 是两个向量之间的夹角

如果两个向量已经归一化:

∣a∣=∣b∣=1|\mathbf a|=|\mathbf b|=1

那么公式瞬间变成:

a⋅b=cos⁡θ\mathbf a\cdot\mathbf b = \cos\theta

于是点积直接变成了夹角的余弦值。

这也是游戏开发中最常见的使用方式。

2为什么 dot > 0 就代表“在前面”?

假设角色朝向:

f\mathbf f

从角色指向敌人的方向:

d\mathbf d

可以计算:

f⋅d\mathbf f\cdot\mathbf d

如果两个向量归一化:

Vector3 direction = (target.position - transform.position).normalized;

float dot = Vector3.Dot(transform.forward, direction);

那么:

dot =  1    完全在正前方
dot >  0    在前方半球
dot =  0    正好在侧面
dot <  0    在后方半球
dot = -1    完全在正后方

为什么?

因为:

f⋅d=cos⁡θ\mathbf f\cdot\mathbf d=\cos\theta

而:

cos⁡θ>0\cos\theta>0

恰好对应:

−90∘<θ<90∘-90^\circ<\theta<90^\circ

也就是前方 180°。

拖动 target,观察 forward · dir 的正负如何对应前/后半球。

所以:

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 的完整视野角是:

90∘90^\circ

那么从中心线向左右各:

45∘45^\circ

于是判断条件是:

θ<45∘\theta < 45^\circ

当然可以真的求:

θ=arccos⁡(f⋅d)\theta = \arccos(\mathbf f\cdot\mathbf d)

但是没有必要。

因为:

cos⁡45∘≈0.707\cos45^\circ\approx0.707

所以只需要:

float dot = Vector3.Dot(forward, direction);

if (dot > Mathf.Cos(45f * Mathf.Deg2Rad))
{
    // Inside FOV.
}

也就是:

f⋅d>cos⁡45∘\mathbf f\cdot\mathbf d>\cos45^\circ
扇形为视野;dot 大于 cos(半角) 时在视野内。

这样一个非常常见的 AI 视野问题,最终仍然只是:

一次点积 + 一次比较。

而且:

Mathf.Cos(halfFov)

甚至可以预先算好。

于是每个目标只需要计算一次点积。

5一个很有用的规律:阈值越大,视野越窄

对于单位向量:

dot=cos⁡θdot=\cos\theta

因为余弦在:

0∘→180∘0^\circ\rightarrow180^\circ

之间是不断下降的。

所以:

Dot 阈值最大夹角
1.00∘0^\circ
0.86630∘30^\circ
0.70745∘45^\circ
0.560∘60^\circ
0.090∘90^\circ
-1.0180∘180^\circ

因此:

dot > 0.9

表示两个方向非常接近。

而:

dot > 0.1

则宽松得多。

这个规律在很多系统里都非常好用:

  • Aim Assist
  • Lock-On
  • AI Vision
  • Interaction
  • Auto Targeting
  • Camera
  • Navigation

6点积其实是在问:“一个向量在另一个方向上有多少?”

这是理解点积更深的一层。

假设:

v\mathbf v

是角色速度。

而:

f\mathbf f

是角色的前方向,并且:

∣f∣=1|\mathbf f|=1

那么:

v⋅f\mathbf v\cdot\mathbf f

表示什么?

答案是:

速度在前方向上的分量。

例如:

拖动 velocity,灰色箭头为在 forward 上的投影分量。

虽然角色可能斜着运动,

但:

v⋅f\mathbf v\cdot\mathbf f

告诉我们:

真正沿 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\mathbf a

投影到单位向量:

b^\hat{\mathbf b}

上。

投影长度就是:

a⋅b^\mathbf a\cdot\hat{\mathbf b}

而完整投影向量则是:

proj⁡b(a)=(a⋅b^)b^\operatorname{proj}_{\mathbf b}(\mathbf a) = (\mathbf a\cdot\hat{\mathbf b}) \hat{\mathbf b}

如果 b\mathbf b 没有归一化:

proj⁡b(a)=a⋅bb⋅bb\operatorname{proj}_{\mathbf b}(\mathbf a) = \frac{\mathbf a\cdot\mathbf b} {\mathbf b\cdot\mathbf b} \mathbf b

这件事非常重要。

因为它意味着点积实际上在干:

把一个向量拆开,看它有多少属于另一个方向。

例如:

          a
         ↗
        /|
       / |
      /  |
─────●───●────────→ b
        projection

这马上就能解释大量游戏物理问题。

8为什么角色撞墙以后还能沿墙滑动?

假设角色速度:

v\mathbf v

墙面法线:

n\mathbf n

其中 n\mathbf n 是单位向量。

速度有一部分指向墙:

(v⋅n)n(\mathbf v\cdot\mathbf n)\mathbf n

那么我们把这一部分从速度里减掉:

vslide=v−(v⋅n)n\mathbf v_{\text{slide}} = \mathbf v - (\mathbf v\cdot\mathbf n)\mathbf n

得到的就是:

沿着墙面的速度。

代码:

Vector3 slideVelocity =
    velocity -
    Vector3.Dot(velocity, normal) * normal;

或者直接:

Vector3.ProjectOnPlane(velocity, normal);

于是一个看起来像“碰撞系统”的问题,核心数学其实是:

找到速度中朝向墙面的部分,把它删掉。

而找这一部分的工具:

还是点积。

竖直墙面、法线 n 向右;拖动 v,虚线为撞向墙面的分量,蓝色虚线为沿墙滑动速度。

9反弹同样来自点积

如果不是沿墙滑动,而是让速度发生理想镜面反射:

r=v−2(v⋅n)n\mathbf r = \mathbf v - 2(\mathbf v\cdot\mathbf n)\mathbf n

为什么这里变成了:

22

?

因为第一次减:

(v⋅n)n(\mathbf v\cdot\mathbf n)\mathbf n

只是把法线方向上的速度减到:

00

而我们想让它继续到相反方向。

所以还要再减一次。

拖动入射速度 v;蓝色虚线为镜面反射 r = v − 2(v·n)n。

于是 Unity 里的:

Vector3.Reflect(direction, normal);

其背后的核心仍然是:

点积。

10地面到底有多陡?

现在来看角色控制器里另一个非常常见的问题。

假设地面法线是:

n\mathbf n

世界 Up 是:

u\mathbf u

那么:

n⋅u=cos⁡θ\mathbf n\cdot\mathbf u = \cos\theta

这里的:

θ\theta

其实就是坡面的倾斜程度。

拖动地面法线 n;读数为 n·up 与坡度角(相对水平面)。

完全平坦的地面:

n=u\mathbf n=\mathbf u

于是:

dot=1dot=1

垂直墙壁:

dot=0dot=0

倒挂的表面:

dot<0dot<0

于是我们可以直接判断:

float groundDot = Vector3.Dot(hit.normal, Vector3.up);

if (groundDot > minGroundDot)
{
    // Walkable ground.
}

比如最大可行走坡度是:

45∘45^\circ

那么:

float minGroundDot = Mathf.Cos(45f * Mathf.Deg2Rad);

也就是大约:

0.7070.707

于是:

if (Vector3.Dot(hit.normal, Vector3.up) > 0.707f)
{
    // Walkable
}

甚至根本没有必要真的计算:

slopeAngle

11光照为什么也是点积?

如果你写过最基础的 Shader,应该见过这样的东西:

float NdotL = max(0, dot(normal, lightDirection));

这就是最经典的:

Lambert Diffuse Lighting

其核心是:

I∝max⁡(0,N⋅L)I\propto\max(0,\mathbf N\cdot\mathbf L)

为什么?

假设光正对着表面:

       ↓ Light

──────────── Surface
       ↑ N

那么:

N⋅L=1\mathbf N\cdot\mathbf L=1

最亮。

当光越来越倾斜:

            ↙ Light

──────────── Surface
       ↑ N

夹角越来越大:

cos⁡θ\cos\theta

越来越小。

于是单位面积实际接收到的能量也越来越少。

当光线与表面完全平行:

θ=90∘\theta=90^\circ

所以:

cos⁡90∘=0\cos90^\circ=0

没有漫反射光。

背面则:

N⋅L<0\mathbf N\cdot\mathbf L<0

因此:

max(0, dot(N, L))

把负数截断。

一个非常基础的实时光照模型,就这样从点积里长出来了。

一个非常漂亮的统一

AI 在问:

目标与我的朝向有多一致?

角色控制器在问:

地面法线与世界 Up 有多一致?

车辆在问:

速度与车头方向有多一致?

Shader 在问:

表面法线与光照方向有多一致?

数学上的答案全部是:

a⋅b\mathbf a\cdot\mathbf b

12为什么两个垂直向量的点积是 0?

从几何公式看:

a⋅b=∣a∣∣b∣cos⁡θ\mathbf a\cdot\mathbf b = |\mathbf a||\mathbf b|\cos\theta

如果:

θ=90∘\theta=90^\circ

那么:

cos⁡90∘=0\cos90^\circ=0

所以:

a⋅b=0\mathbf a\cdot\mathbf b=0

这给了我们一个非常方便的判断:

两个非零向量点积为 0,则互相垂直。

这在构造坐标系、检测正交关系、几何算法中非常常见。

例如:

bool approximatelyPerpendicular =
    Mathf.Abs(Vector3.Dot(a.normalized, b.normalized)) < 0.001f;

13为什么两个相反方向的向量点积是负数?

如果:

90∘<θ≤180∘90^\circ<\theta\le180^\circ

那么:

cos⁡θ<0\cos\theta<0

所以:

a⋅b<0\mathbf a\cdot\mathbf b<0

这就是为什么点积天然能够区分:

同一个半球

和:

相反半球

这也是:

dot > 0

判断前后如此好用的原因。

14点积到底为什么会等于余弦?

到这里可能会产生一个更根本的问题:

为什么几个坐标相乘再相加,居然能得到两个方向之间的夹角?

这个关系并不是凭空出现的。

考虑三角形:

A ●
   \
    \
     \
      ● B

O ●

两个向量:

a\mathbf a b\mathbf b

它们的差:

a−b\mathbf a-\mathbf b

根据余弦定理:

∣a−b∣2=∣a∣2+∣b∣2−2∣a∣∣b∣cos⁡θ|\mathbf a-\mathbf b|^2 = |\mathbf a|^2 + |\mathbf b|^2 - 2|\mathbf a||\mathbf b|\cos\theta

另一方面:

∣a−b∣2=(a−b)⋅(a−b)|\mathbf a-\mathbf b|^2 = (\mathbf a-\mathbf b)\cdot(\mathbf a-\mathbf b)

展开:

=a⋅a−2a⋅b+b⋅b= \mathbf a\cdot\mathbf a -2\mathbf a\cdot\mathbf b +\mathbf b\cdot\mathbf b

而:

a⋅a=∣a∣2\mathbf a\cdot\mathbf a=|\mathbf a|^2

于是:

∣a−b∣2=∣a∣2+∣b∣2−2a⋅b|\mathbf a-\mathbf b|^2 = |\mathbf a|^2 + |\mathbf b|^2 - 2\mathbf a\cdot\mathbf b

和余弦定理比较:

a⋅b=∣a∣∣b∣cos⁡θ\mathbf a\cdot\mathbf b = |\mathbf a||\mathbf b|\cos\theta

因此:

点积的“代数定义”和“几何定义”其实是同一件事情的两种描述。

这也是线性代数中非常漂亮的一点:

坐标运算和几何关系被一个公式连接了起来。

15点积自己其实不会告诉你“左还是右”

这里有一个非常常见的误区。

假设目标分别位于:

       A
      ↖

──────→ Forward

      ↙
       B

如果 A 和 B 与 Forward 的夹角大小相同:

θA=θB\theta_A=\theta_B

那么:

forward⋅A=forward⋅Bforward\cdot A = forward\cdot B

点积只知道:

夹角有多大。

但它不知道:

目标是在左边还是右边。

因为:

cos⁡(−θ)=cos⁡(θ)\cos(-\theta)=\cos(\theta)

左右信息在余弦里消失了。

所以:

点积擅长回答“有多对齐”。

如果还需要判断左右,就通常要结合:

叉积 Cross Product。

例如:

Vector3 cross = Vector3.Cross(forward, direction);

float side = Vector3.Dot(cross, up);

这也正好可以成为下一篇文章的话题:

点积告诉你“有多朝向”,叉积告诉你“朝哪边偏”。

16Dot Product 最大的坑:忘记向量长度

如果:

a\mathbf a

和:

b\mathbf b

不是单位向量,那么:

a⋅b\mathbf a\cdot\mathbf b

并不只是:

cos⁡θ\cos\theta

而是:

∣a∣∣b∣cos⁡θ|\mathbf a||\mathbf b|\cos\theta

例如:

Vector3 a = new Vector3(10, 0, 0);
Vector3 b = new Vector3(10, 0, 0);

float dot = Vector3.Dot(a, b);

结果不是:

1

而是:

100100

因为:

10×10×cos⁡0∘=10010\times10\times\cos0^\circ=100

所以如果你的目的真的是比较:

方向

通常应该:

float dot = Vector3.Dot(a.normalized, b.normalized);

不过也不能机械地认为:

Dot 前一定要 Normalize。

因为有时候向量长度本身就是有意义的。

比如:

v⋅f\mathbf v\cdot\mathbf f

用来求 Forward Speed 时,

我们恰恰希望保留:

∣v∣|\mathbf v|

只需要保证:

f\mathbf f

是单位向量。

因此更准确的规则是:

先理解公式里的向量长度代表什么,再决定是否 Normalize。

17如果真的要求角度,记得 Clamp

如果确实需要从点积恢复角度:

θ=arccos⁡(a⋅b)\theta = \arccos(\mathbf a\cdot\mathbf b)

对于单位向量,理论上:

−1≤dot≤1-1\le dot\le1

但浮点数计算可能出现:

1.0000001

这样的微小误差。

而:

arccos⁡(1.0000001)\arccos(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 Space

Shader 中又可能存在:

World Space
View Space
Tangent Space

如果要做点积,就必须先把它们转换到同一个空间。

这也是为什么游戏图形学代码里会不断出现:

Object → World
World → View
Tangent → World

很多“Shader 怎么看起来不对”的问题,最后并不是 Dot 写错了。

而是:

你拿两个不同世界里的方向做了点积。

19点积其实是在做“相关性”

如果把视角再拉高一点,会发现点积还有一种很有意思的理解:

衡量两个对象有多相似。

例如单位向量:

完全一样       →  1
完全无关/正交   →  0
完全相反       → -1

这种思想并不只存在于 3D 图形学。

在更高维空间里依然成立。

一个向量可以不再是:

(x,y,z)(x,y,z)

而是:

(x1,x2,x3,…,x1000)(x_1,x_2,x_3,\dots,x_{1000})

点积仍然是:

a⋅b=∑iaibi\mathbf a\cdot\mathbf b = \sum_i a_ib_i

于是它开始连接到:

  • Signal Processing
  • Machine Learning
  • Neural Networks
  • Embeddings
  • Cosine Similarity

比如机器学习中经常使用的:

Cosine Similarity

其实就是:

similarity⁡(a,b)=a⋅b∣a∣∣b∣\operatorname{similarity}(a,b) = \frac{a\cdot b} {|a||b|}

如果已经 Normalize:

similarity⁡(a,b)=a⋅b\operatorname{similarity}(a,b) = a\cdot b

突然之间:

游戏里判断“敌人是不是朝着我”

和:

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)

求表面接受多少光。

它们看起来完全是不同系统:

Loading diagram…

但它们真正的问题全部可以重新描述成:

一个方向,在另一个方向上有多少?

或者:

两个方向到底有多一致?

而数学恰好给了我们一个极其便宜的操作:

a⋅b\boxed{ \mathbf a\cdot\mathbf b }

21为什么点积值得真正理解,而不只是记住 API?

当然,你完全可以只记住:

Vector3.Dot(a, b);

然后查文档:

> 0 same-ish direction
< 0 opposite-ish direction

这已经足够写很多游戏逻辑。

但真正理解:

a⋅b=∣a∣∣b∣cos⁡θ\mathbf a\cdot\mathbf b = |\mathbf a||\mathbf b|\cos\theta

以后,你会开始主动发现很多问题其实都可以用 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;

但背后却连接着:

a⋅b=∣a∣∣b∣cos⁡θ\mathbf a\cdot\mathbf b = |\mathbf a||\mathbf b|\cos\theta

进一步连接到:

  • 夹角
  • 投影
  • 正交
  • 坐标分解
  • 光照
  • 碰撞
  • AI
  • 角色控制
  • 向量空间

一个看起来只有三个乘法、两个加法的公式,

却可以回答半个 3D 游戏世界里的问题。

也许这正是游戏数学最有意思的地方:

真正重要的数学往往不是复杂,而是足够基础,以至于可以反复出现在完全不同的问题里。

当你真正理解它以后,你开始看到的不再是:

“这里应该调用哪个 API?”

而是:

这个游戏问题,本质上到底是什么几何关系?

CONTINUE EXPLORING

沿着这篇文章继续

在地图中查看

On this page