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

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

<!--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

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

从向量的角度来看,将物体所在位置的位置向量乘以 -ω^2 作为加速度,就会形成以原点为中心的圆周运动

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

2. 向量

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

参考

https://juejin.cn/post/6844903859689619469

https://moejj.com/cocos-creatorjiao-cheng-zhi-xiang-liang-de-miao-yong/

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

2.1. 加减乘除

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

加法满足交换律,结合律

a + b = b + a
a + b + c = a + (b + c)

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

a * b  = b * a
a * b * c = a * (b * c)
k * (a + b )  = k * a +  k * b

2.2. 向量大小

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

|v| = Math.sqrt(x^2 + y ^ 2)

V(3,4)的模即为5

2.3. 向量归一化

与向量在同一方向上但单位长度为 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)

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

2.4. 向量分解

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

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

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

2.5. 点乘

常规的点乘公式

u * v = ux * vx + uy * vy

点乘还可以得到两个向量之间的夹角 a

u * v = |u| * |v| * cosa

因为归一化的关系,

Nu = u / |u|

cosa = Nu * Nv

3. 向量的应用

下面是最近在复刻《保卫萝卜》时遇见的一些向量应用场景,感觉可以放在这里

参考:

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

每个炮塔需要遍历怪物列表,找到距离炮塔自身最近的那个怪物作为目标。

因此需要获得炮塔和每个怪物之间的距离 勾股定理可以轻松解决,当然也可以使用向量

问题抽象:两个向量之前的距离等于他们之差的模

const distance = p2.sub(p1).mag()

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

当炮塔的攻击范围内出现怪物时,需要将炮管的对准怪物的位置

问题抽象为:让一个对象朝向另一个对象, 例如求点(0,1)到点(1,0)之间的方向, 实际上是求两个向量之差

function lookAt(p1, p2){
  const dx = p2.x - p1.x;
  const dy = p2.y - p1.y;
  const dir = cc.v2(dx, dy);
  const angle = dir.signAngle(cc.v2(1, 0));  // 与x轴正方向的夹角,rotation的值也是以这个为标准

  const angle2degree = (angle) => {
    return angle * 180 / Math.PI // 弧度转角度
  }
  const degree = angle2degree(angle)
  p1.rotation = degree
}

cc.Vec2提供了向量相减的方法,因此可以更简单的实现

const angle = p2.sub(p1).signAngle(cc.v2(1, 0))
const degree = angle2degree(angle)
p1.rotation = degree

3.3. 让一个对象朝着他的方向移动

炮管锁定怪物位置后,需要发射子弹,子弹会朝其初始化的方向做直线运行

问题抽象为:让一个对象朝着他的方向移动,比如一个子弹的初始rotation的角度是45度,则节点会一直朝正右下方移动

首先根据节点的rotation计算出对应方向的单位向量

const rotation = this.node.rotation
// 算出弧度
const angle = rotation / 180 * Math.PI
const dir = cc.v2(Math.cos(angle), Math.sin(angle))
// 单位化向量
dir.normalizeSelf()

然后在update的时候计算出对应的偏移即可

const moveSpeed = 100;
this.node.x += dt * dir.x * moveSpeed;
this.node.y += dt * dir.y * moveSpeed;

4. 游戏引擎中的物理系统

在游戏中,我们经常需要去模仿真实场景的物理行为,比如《愤怒的小鸟》抛物线的弹道,汽车的碰撞等,如果我们通过编码来实现这些物理模拟,将会涉及许多数学和物理公式,实现起来既复杂又容易出错,这时就需要用到物理引擎啦。物理引擎可以给物体赋予物理属性,使物体产生运动、碰撞以及旋转,从而模拟真实世界物体的运动。它可以让我们的游戏世界变得更加真实。

  • Collider,Trigger
  • Rigidbody,力,速度
  • Physics Material
  • Joint 关节
  • Raycast 光线投射
  • 2D Physics

虽然各个游戏引擎实现存在差异,但都提供了一些基础功能。

5. 冲量

参考:

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

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

f * dt = m * dv

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

6. 刚体

刚体(rigidbody)是指在运动中或者受力后形状和大小不变,以及内部点的相对位置都不会改变的物体。在现实世界中,刚体是不存在的,因为无论再坚硬的物体,受到力的作用后都会产生形变,然而对于程序而言,刚体的特性是最容易模拟和实现的。

下面整理了一些容易遇见的问题

6.1. 移动刚体时碰撞区域改变

  • 静态碰撞体:不挂载刚体组件的碰撞体

常用于地形、障碍物等不会移动位置的物体,物理引擎会对此优化性能。在游戏运行时,不应当改变静态碰撞体的 enabled 选项或移动、缩放碰撞体,否则会给物理引擎带来额外的计算工作量,导致性能显著下降。

  • 刚体碰撞体:挂载了刚体组件(非 Kinematic)的碰撞体

物理引擎会一直模拟计算刚体碰撞体的物理状态,会对碰撞以及施加的力做出反应。

  • Kinematic 刚体碰撞体:挂载了刚体组件且刚体组件设置为 Kinematic 的碰撞体

可以尝试通过给刚体一个力的形式applyForceToCenter,来修改刚体的位置

参考:让刚体运动起来

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

控制刚体的移动可以通过设置this.node.getComponent(cc.RigidBody).linearVelocity来实现

6.2. 刚体类型区别

https://www.cxyzjd.com/article/qq_35131940/77584982

需要区分碰撞组件和物理组件

6.3. 刚体边界卡顿

参考:

使用 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();

7. 光线

TODO 待补充

8. 小结

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

热更新实现原理游戏开发中的长地图和摄像机