游戏开发中的数学和物理基础知识

游戏开发中需要掌握一些基础的数学、物理等知识,本文将对这些知识进行整理

<!--more-->

由于博客暂时不支持 Latex,因此涉及到的部分公式暂时使用伪代码来实现

1. 运动

1.1. 直线运动

最基本的直线运动公式

位置 = 位置 + 速度
速度 = 速度 + 加速度

将速度分解为水平和竖直方向上的分速度


// 初始化
start(){
    this.x = 0
    this.y = 0
    this.dx = 1 // 水平方向的速度
    this.dy = 2 // 竖直方向的速度
}

// 每一帧绘制
update(){
    this.x += this.dx
    this.y += this.dy
}

调整运行运动方向也很简单,只要反转dxdy的符号就可以了,比如运动体在到达右边界的时候反转向左移动,

update(){
    // objectWidth物体宽度,screenWidth窗口宽度,假设都已左上角为坐标原点
    // 判断一下是否到达编辑
    if(this.x + objectWidth >= screenWidth) {
        this.dx = -this.dx
    }
    // ...
}

上面演示的是在 update 的时候自动更新位置,在有的时候是通过用户操作来控制的,比如按下方向键的时候才朝对应的位置移动,松开按键则停止移动

一个简单的技巧是使用一个变量来保存哪些按键被按下了,然后在 update 的时候根据按键来做逻辑处理

const Input = []

init(){
    systemEvent.on(KEY_DOWN, (e)=>{
        Input[e.keyCode] = true
    })

    systemEvent.on(KEY_UP, (e)=>{
        Input[e.keyCode] = false
    })
}

update(){
    if(Input[KEY_CODE.left]) {
        // 朝左移动
    }
    if(Input[KEY_CODE.up]) {
        // 朝上移动
    }
}

1.2. 圆周运动

将圆的一周表示为 2*PI 的度量单位,称为弧度,弧度代表半径为 1、圆心角为 θ 的圆弧,其弧长为 θ。

为什么计算机的正余弦函数中基本上使用的是弧度而不是看起来更直观的角度呢?这跟微积分的计算有关,游戏中特别是物理公式,稍微复杂就会出现微积分计算,使用角度参与计算十分麻烦。因此在游戏开发中角的单位基本上都使用弧度

接下来看看圆周运动,只要知道圆心和半径,以及旋转的角度,就可以知道圆上某一点的坐标

let fAngle = 0;

const raduis = 50;
const centerPoint = {
    x: 250,
    y: 250,
};

update (){
    fAngle += (2 * Math.PI) / 360; // 每帧增加的弧度

    if (fAngle > 2 * Math.PI) {
        fAngle -= 2 * Math.PI; // 避免弧度过大导致精度丢失
    }

    this.node.x = centerPoint.x + raduis * Math.cos(fAngle);
    this.node.y = centerPoint.y + raduis * Math.sin(fAngle);
};

三角函数看起来比较直观,接下来从物理学的角度看一下

上面使用(2 * Math.PI) / 360来控制物体的旋转速度,在物理学中被称为角速度,角速度一般写作 ω,可以表示为 θ=ωt 表示在某个时间内转过的角度,一般把转过一周的时间称为周期 T,则角速度公式为

w = 2*PI / T

对物体施加一个指向某一点的角速度为 ω 的力,物体就会围绕该点做圆周运动,由于这个力始终指向旋转的中心,因此称为向心力

从向量的角度来看,将物体所在位置向量按角速度 w 旋转,就会形成以原点为中心的圆周运动。

由于三角函数比简单的四则运算要耗时更多,因此使用向心力实现的圆周运动,比使用三角函数实现的圆周运动在运算速度方面更胜一筹。

2. 向量

向量在游戏开发中非常常见,需要复习一下。

参考

简单理解,向量是同时包含大小和方向的值

2.1. 加减乘除

向量也可以参与数学运算,如加减乘除

向量加减法满足交换律,结合律

当我们说向量的乘法时,一般指的是改变向量的长度,是将向量乘以一个标量(下面会介绍向量乘以向量的情况)

// 延长3倍
new Vec2(1,1).multiply(3) //  Vec2 { x: 3, y: 3 }
// 缩短1倍
new Vec2(42).divide(2) //  Vec2 { x: 2, y: 1 }

乘法满足交换律,结合律和分配律

2.2. 向量的大小与归一化

上面提到了向量的长度,实际上就是向量的大小,向量的大小就是他的模|v|,在坐标轴上就表示长度,利用勾股定理可以得出

new Vec2(3, 4).length(); // 5

在某些时候,我们可能关心的是向量的方向,而不是向量的大小,这就需要用到向量归一化。

归一化向量指的是与给定向量*方向相同但单位长度为 1 *的向量,即N = v / |v|,使用除法运算,则 V(3,4)归一化后为

N = V(3,4) / |V(3,4)|
N = V(3,4) / 5
N = V(0.6, 0.8)

注意长度为 0 的向量不能被归一化!

const dir = new Vec2(3, 4).normalize(); // 与Vec2(3,4) 方向相同但单位为1的向量

看起来只是求了一个单位向量,但在很多时候可以简化计算

2.3. 向量的方向角度

将向量分解成一个水平方向上的向量 vx 和一个竖直方向上的向量 vy,这样就可以求得向量与坐标轴的夹角

设 v 在 y 轴上分解得到的长度为|vy|,x 轴上分解得到的长度为|vx|,v 与 y 轴的夹角为 d

cosd = |vy| / |v|
sind = |vx| / |v|

2.4. 点乘

常规的点乘公式

u . v = ux * vx + uy * vy

其结果相当于将一个向量 u 在另一个向量上 v 的投影大小再乘以 v 的大小

向量的点乘可以用来比较两个向量之间的相似性,当两个向量方向一致时,其点乘结果越大。

此外,点乘还可以得到两个向量之间的夹角 d

由于

u . v = |u| * |v| * cosd

因为归一化的关系,

Nu = u / |u|

cosa = Nu * Nv

2.5. 叉乘

叉乘仅对三维向量适用,会得到一个与两个向量在空间上都垂直的向量

3. 向量的应用

上面列举了很多计算公式,但看起来比较空洞,下面列举一些真实的例子。

参考:

3.1. 获取两个点之间的距离

在开发中,经常需要获取两个坐标的距离。

比如在塔防游戏中,每个炮塔需要遍历怪物列表,找到距离炮塔自身最近的那个怪物作为目标,因此需要获得炮塔和每个怪物之间的距离 勾股定理可以轻松解决,当然也可以使用向量

let p1 = new Vec2(1, 2);
let p2 = new Vec2(3, 4);

p1.distance(p2); // 两点之间的距离

两个位置向量的距离,实际上就是他们之差的模

p2.subtract(p1).length(); // 与上面结果相同

3.2. 让一个对象面朝另一个对象

同样还是在塔防游戏中,当炮塔的攻击范围内出现怪物时,需要将炮管的对准怪物的位置

让一个对象 A 朝向另一个对象 B,实际上是求 A 需要旋转的角度;例如求点(0,1)到点(1,0)之间的方向,实际上是求两个向量之差 C,然后求 C 旋转的角度

let p1 = new Vec2(1, 1);
let p2 = new Vec2(2, 0);
let angle = new Vec2(1, 0).angleTo(p2.subtract(p1)); // 与x轴正方向的夹角
let degree = (angle / Math.PI) * 180; // -45

3.3. 物体直线移动

在游戏中,发射的常规子弹会沿着他的初始方向做直线运动。

我们可以把向量当作两点之间的差异,也就是从一个点到另一个点所发生的移动

  • 位置向量给我们一个相对于原点的位置
  • 速度向量告诉我们如何从一个位置移动到另一个位置

假设物体位于4,4处,使用位置向量new Vec2(4,4)表示;其 x 方向速度为 1,y 方向速度为 2,使用速度向量new Vec2(1,2)

let location = new Vec2(4, 4);
let velocity = new Vec2(1, 2);

物体做匀速运动,根据公式 新位置 = 原位置 + 速度*时间,则可以得到任意时刻物体运动后的位置

function update() {
    location.add(velocity);
    console.log(location);
}
update(); // Vec2 { x: 5, y: 6 }
update(); // Vec2 { x: 6, y: 8 }
update(); // Vec2 { x: 7, y: 10 }

如果物体是做匀加速运动,则每次 update 中,除了 location 的变化,还有 velocity 的变化

let acceleration = new Vec2(-1, 1);

function update() {
    velocity.add(acceleration); // vt = v0 + at
    location.add(velocity);
    console.log(location);
}

update();
update();

3.4. 圆周运动

圆周运动是一种最常见的曲线运动,分为匀速圆周运动和变速圆周运动。

对于正在做圆周运动的物体,其每个时刻在圆周上的位置,都可以看做是大小不变、方向改变的向量,因此只需要在旋转初始向量,就可以实现圆周运动

// 定义圆周运动的一些初始值
const r = 4;
const T = 4;
const w = (2 * Math.PI) / T; // 角速度

let location = new Vec2(r, 0); // 初始位置
function update() {
    location.rotate(w); // 每帧旋转的角度
    console.log(location);
}

update(); // Vec2 { x: 0, y: 4 }
update(); // Vec2 { x: -4, y: 0 }
update(); // Vec2 { x: -0, y: -4 }
update(); // Vec2 { x: 4, y: -0 }

震惊!!完全不需要写三角函数计算了。

4. 三角函数

角度是我们非常熟悉的单位,一个完整的旋转是从0度转到360度。

弧度也是角的度量单位,它是角所对的弧长除以半径后得到的值,180度 = π弧度,90度 = π/2弧度。

弧度 = 2π × (角度 / 360)

物体的旋转运动与直线运行非常相似

角度 = 角度 + 角速度
角速度 = 角速度 + 角加速度

角度只是一个标量,而不是向量~,他的方向只有顺时针和逆时针,使用正负号就可以区分。

// 让物体顺时针旋转30度
this.node.rotation = +30

4.1. sohcahtoa

这个次看起来是一个没有意义的单词,实际上是三角函数的记忆口诀

  • soh:正弦(sin) = 对边(opposite) / 斜边(hypotenuse)
  • cah :余弦(cos) = 邻边(adjacent) / 斜边(hypotenuse)
  • toa :正切(tan) = 对边(hypotenuse) / 邻边(adjacent)

三角函数主要是在已知夹角的情况下,求其余边的长度。

另外一种情况是在已知边长的情况下求夹角的大小,被称之为反三角函数,使用arcsinarccosarctan表示。

大部分的编程语言都提供了Math模块来实现三角函数,我们就不用再去查表了~

4.2. 波

5. 力

牛顿三定律描述了物体的外力与物理所呈现出运动的关系

  • 在没有外力作用下孤立质点保持静止或做匀速直线运动(惯性定律)
  • 施加于质点的外力等于该质点的质量乘以该质点的加速度(加速度定律)
  • 相互作用的两个质点之间的作用力和反作用力总是大小相等,方向相反,作用在同一条直线上(作用力与反作用力定律)

5.1. 加速度

在游戏开发中,最重要的就是第二点:加速度定律。力有大小,也有方向;换言之,力也是一个向量

const force = new Vec2(100, 0)

对一个物理施加外力,会改变物体的加速度

// 这里也可以体会到向量在运算中的威力!!
let acceleration = new Vec2(0, 0) // 物体原本静止
let mass = 1 // 物体的质量
function applyForce(force){
  const acc = force.divide(mass) // F = m * a
  acceleration.add(acc) // 修改物体的加速度
}

applyForce(new Vec2(100,0))
applyForce(new Vec2(50,0))
// 力的效果会叠加

加速度会修改速度,速度会影响物体的位置,这样就达到了对应的视觉效果。

5.2. 冲量

参考:

在经典力学里,物体所受合外力的冲量等于它的动量的增量(即末动量减去初动量),叫做动量定理。

一个恒力的冲量指的是这个力与其作用时间的乘积,冲量表述了对质点作用一段时间的积累效应的物理量,是改变质点机械运动状态的原因。

f * dt = m * dv

其中f是作用在物体上的恒力,dt是作用时间,m是物体的质量,dv是作用时间内物体速度的改变量

6. 物理引擎

在游戏中,我们经常需要去模仿真实场景的物理行为,比如《愤怒的小鸟》抛物线的弹道,汽车的碰撞等,如果我们通过编码来实现这些物理模拟,将会涉及许多数学和物理公式,实现起来既复杂又容易出错,这时就需要用到物理引擎。

物理引擎可以给物体赋予物理属性,使物体产生运动、碰撞以及旋转,从而模拟真实世界物体的运动。它可以让我们的游戏世界变得更加真实。

大部分游戏引擎都内置了一个或多个物理引擎,比如 cocos creator 在2D物理中 内置了轻量的Builtin和功能强大的 Box2D 物理系统,可以在项目设置中进行切换

不同的物理引擎功能存在差异,但都提供了一些基础概念,如

  • 刚体
  • 碰撞体

6.1. 刚体

刚体rigidbody是指在运动中或者受力后形状和大小不变,以及内部点的相对位置都不会改变的物体。

在现实世界中,刚体是不存在的,因为无论再坚硬的物体,受到力的作用后都会产生形变,然而对于程序而言,刚体的特性是最容易模拟和实现的。

下面列举了Box2D的刚体类型

  • 动态刚体Dynamic,一个“完全模拟”的物体,能执行运动、碰撞,并能感受到环境中的力,在大部分时候我们可能都会使用这种类型
  • 静态刚体Static,静态物体不能发生位移(速度为0,不会受到力的作用),主要用来模拟某些平台和边界,如地面、墙体等,物理引擎会对静态刚体进行性能优化
  • 运动刚体Kinematic,0质量,不受力的作用,但是可以设置速度来移动,主要用来设置那些完全由用户控制的对象物体

当为物体添加了刚体组件之后,就可以通过施加外力或者修改速度来控制刚体的位置,参考:让刚体运动起来

  • 通过重力
  • 通过外力
  • 通过冲量
  • 直接改变速度,linearVelocity

6.2. 碰撞体

假设不借助物理引擎,我们该如何实现下面的功能

  • 如何确定两个物体是否发生碰撞
  • 如何确定碰撞后物体各自的速度

碰撞检测需要考虑两个物体的形状,并判断在两个物体是否有面积重合的部分,

  • 圆形物体,判断两个圆心是否小于半径之和
  • 矩形物体...
  • 不规则多边形物体...

碰撞速度就需要借助牛顿第三定律,作用力与反作用力。想想都很麻烦。

正因如此,物理引擎大都会提供碰撞体collider组件的功能,当两个碰撞体发生碰撞时,可以通过某些回调通知代码并运行相关逻辑。

6.3. ghost collision

参考:

使用 tiledmap 绘制的地图,并遍历瓦片依次为每个瓦片添加PhysicsBoxCollider,发现在看起来是水平的地面上移动时,有时候会出现边界卡顿

 这个问题有个专门的名字,叫做ghost collision(鬼打墙)

一种解决办法是使用PhysicsPolygonCollider,通过创建一个整体的多边形碰撞区域来实现

那么如何获取多边形的顶点数组呢?可以通过算法,也可以在 tiledmap 中绘制一个相同的多边形,然后通过object.points来获取多边形的顶点

let layer = this.map.getLayer("ground");
let colliderLayer = this.map.getObjectGroup("collider");

const objects = colliderLayer.getObjects();
const object = objects[0];

const { points, offset } = object;

const ccv2Points = [];
for (let j = 0; j < points.length; j++) {
    ccv2Points.push(cc.v2(points[j].x, points[j].y));
}

let body = layer.node.addComponent(cc.RigidBody);
body.type = cc.RigidBodyType.Static;

const collider = layer.node.addComponent(cc.PhysicsPolygonCollider);

collider.offset = cc.v2(offset.x, -offset.y);
collider.points = ccv2Points;
collider.apply();

6.4. 关节

关节Joint能将两个物体连接在一起,常用于一些高级物理模拟,比如钟摆的摆动、弹簧连接、粘性物体和轮子滚动等。

这个等具体用到的时候再来补充。

6.5. 光线

在3D游戏中,光线也是一个无法绕开的话题。

这个等具体用到的时候再来补充

7. 自治智能体

为了让游戏看起来更加丰富有趣,往往需要添加一个看起来能自由移动的怪物、NPC等,这些都属于自治智能体。

自治智能体指的是那些根据自身意愿做出行为决定的主体,下面列出了自治智能体最重要的3个特性

  • 自治智能体对环境的感知能力是有限的
  • 自治智能体需要处理来自外部环境的信息,并由此计算具体的行为
  • 自治智能体没有领导者,这一点往往不是特别关心

比如我们现在正在实现一个《孤胆枪手》的复刻游戏,怪物会源源不断地朝枪手移动,玩家需要控制枪手躲避怪物,并朝怪物开枪,这里的怪物就是一个智能体

  • 为了模拟怪物的移动,不可能让怪物直接瞬移到枪手的位置
  • 怪物知道当前枪手的位置,并及时调整自己的速度方向

关于智能体的原理和实现,可以先阅读下面这两本书

  • 《游戏人工智能编程案例精粹》
  • 《代码本色:用编程模拟自然系统》

待我学成归来,单独开一篇文章写一下。

8. 小结

本文整理了一些游戏开发中常见的数学和物理知识,后面会陆续补充。