<
Tutorial教程

手把手做一个2D物理引擎吧! - Part 1

HI~,我CodeTiger回来啦!
感觉自己真的好懒啊233,过了差不多半年才更新一次自己博客。上次还保证多上传(oops)。
无论如何,这次我向天发誓一定会多更新的(立Flag)!反正疫情无聊我也干不了啥╮(╯▽╰)╭。
Oh对,以上期博客来看,似乎大家不太喜欢我中英混的风格,那我这次就完全用中文吧。不过我在想过段时间加一个中英互换模式,这样可以解决我一直不知道如何定位的问题o(╥﹏╥)o。

咳咳,废话不多说,我们切入正题。
如你所见,我又准备开新坑了233。主要是编译器实在太神马烦了。本身我想做一个类似C的编译器,但是我又不会生成机器码,所以只能写解释器,但又感觉似乎有点没意思,所以就果断先弃坑了。
可能过段时间我会写一个类似Java JVM虚拟机的编译器?但是目前来说emmmmm。

(声明: 我还是物理引擎萌新,所以这更像是一个我的学习过程。So请谨慎食用以下内容。)
好,我们从啥是物理引擎开始。实际上就是一个物理模拟器,你可以用来做游戏,模拟天体,反正想干啥就干啥。
那我们就要解决这么几个问题。

1. 如何让物体运动
2. 如何快速检查物体是否碰撞
3. 如何碰撞了,如何给他们释力让他们运动
4. 如何通过简单的物体拼装更复杂的形状 (这个今天先不讲)

最好的话,你已经会一些基本线性代数了,不过如果不会也可以,只不过可能很多地方你就只能相信我说的是真的就算了,不能真正一步步数学证明。

在这个教程里面,我们先处理最简单的情况:
在一个没有摩擦力的环境里,让一堆圆球互换碰撞,最后成果大概是这个样子的:

(点击画幕来重放模拟)

这东西看起似乎很容易,但是实际上涉及到很多神奇的数学和物理知识。我们一步一步来。




STEP1 - 有个物体而且他可以运动

由于我们现在只考虑圆,那么他应该就只有这几个属性:
位置: 一个向量 (x,y)
速度: 一个向量 (V_x,V_y)
质量: 一个数字 mass
半径: 一个数字 r

那我们就愉快的写一个这样的Object吧
首先为了方便,我们写一些基本向量运算的函数 (以下教程会用JavaScript写,但是其他语言应该也可以做到类似的东西)
function vec2(posX,posY) {
    this.x = posX;
    this.y = posY;
}

// 向量相加
function vec2Add(v1,v2) {
    return new vec2(v1.x + v2.x,v1.y + v2.y);
}

// 向量相减
function vec2Subtract(v1,v2) {
    return new vec2(v1.x - v2.x,v1.y - v2.y);
}

// 向量点积
function vec2DotProduct(v1,v2) {
    return v1.x * v2.x + v1.y * v2.y;
}

// 向量乘以常数
function vec2MultiplyConst(v,c) {
    return new vec2(v.x * c,v.y * c);
}
                        

奈斯!然后我们再把圆形Object给写了
function Circle(position,radius,mass,velocity) {
    // 位置
    this.pos = position;
    // 半径
    this.r = radius;
    // 质量
    this.m = mass;
    // 速度
    this.vel = velocity;
}
                        

但是,我们需要一个方法来让这个圆动,因为现在我们没有时间这个概念,所以我们需要一个方法来模拟时间。我们可以通过P5JS来实现这点。
P5JS实际上就是一个类似动画的一个js 插件。它有两个函数:
setup: 初始化函数,会在开始被执行一次
draw: 绘画函数,执行一次就是一帧,会被无限循环,也就是我们用来模拟时间的方法

// 位置,半径,质量,速度
var circle1 = new Circle(new vec2(50,50),40,10,new vec2(1,1));

function setup() {
    // 创建一个450x300的画布
    createCanvas(450,300);
}

function draw() {
    // 把背景变成灰色
    background(51);
    // 圆在这一帧里面要做的东西
    circle1.act();
    // 把圆显示出来
    circle1.show();
}
                        
如果现在直接运行的话会直接报错,因为我们还没有写act和show函数,所以我们就写咯

Circle.prototype.show = function() {
    // 把颜色设置成白色
    fill(255)
    // 在pos的地方画一个直径为2*r的圆
    ellipse(this.pos.x,this.pos.y,2 * this.r,2 * this.r);
}

Circle.prototype.act = function() {
    this.pos.x += this.vel.x;
    this.pos.y += this.vel.y;
}
                        

第二个函数可能需要稍微解释一下
在这里,速度实际上可以当成每一帧里,x坐标增加多少,和y坐标增加多少。所以可以直接加到pos里

(点击画幕来重放模拟)
YAYYY,我们现在有一个可以动的圆了!第一部分COMPLETE


STEP2 - 检查是否碰撞

好消息是,检查碰撞非常非常滴简单(我才不会告诉你这其实是因为我太懒了所以只选择圆的原因)。
两个圆只有在他们圆心距离小于他们半径和才相交,so我们可以非常easy的写一个检查碰撞的函数

function vec2Dist(o1,o2) {
    // 简单的勾股定理来算两点距离
    return Math.sqrt((o1.x - o2.x) * (o1.x - o2.x) + (o1.y - o2.y) * (o1.y - o2.y));
}

function checkCollision(c1,c2) {
    if(vec2Dist(c1.pos,c2.pos) <= (c1.r + c2.r)) {
        return true;
    }
    return false;
}
                        

okay,第二部分写完了233!

STEP3 - 真正难的物理部分,让他们碰撞以后产生力让他们分开来

Emmmm,说实话这部分我也有一点点不熟,不过我们从最基本的相对论相对速度开始

我们先假设有两个圆,一个叫圆A,一个叫圆B。
那么,我们知道从A的角度,他自己是静止的,而B的速度是
$$V^{AB} = V^{B} - V^{A}$$ 同样,B看A就是 $$V^{BA} = V^{A} - V^{B} = -V^{AB}$$ 好,这里会涉及到一点线性代数。当物体相撞的时候,其实他们的速度只会受到法线向量这个方向的加速或减少。BTW法线向量就是他们的相对位置(我知道这一部分可能会很费解,但是尽量懂吧。这一步懂了后面就简单了)。
也就是相当于,如果我们把这个空间稍微旋转一下,让这两个圆在水平线上相撞,那么从自觉来看,他们受到的力应该也是在水平线上的吧。但是由于我们旋转了,所以我们要把它给旋转回去。而本来的他们两之间的水平线,旋转完以后就是法线
但是我们不需要知道这条线到底多长,我们只需要他到底指向哪里,所以我们可以除以这条线的长度来得到一个长度为1的向量。
写出来就是这样的
$$P^{AB} = P^{B} - P^{A}$$ $$Normal = (P^{AB} \div |P^{AB}|)$$ 这里P是他们的位置,Normal就是法线向量

Okay(o(╥﹏╥)o感觉我讲的好乱),假如你听懂了我上面的胡话,那么我们就可以直接把那个相对速度映射到法线上来得到法线上的相对速度啦。如果你熟悉线性代数的话,你就知道我们可以直接算他们的点积,也就是
$$VelocityAlongNormal = Normal \cdot V^{AB}$$ (VelocityAlongNormal就是相对于法线的相对速度)。这里上代码
// 计算法线向量
function getNormal(v1,v2) {
    // 计算法线长度
    var leng = vec2Dist(v1,v2);
    // 计算法线
    var Normal = vec2Subtract(v2,v1);
    // 法线除以法线长度
    return new vec2(Normal.x / leng,Normal.y / leng);
}


function resolveCollision(o1,o2) {
    // 计算相对速度
    var relativeVelocity = vec2Subtract(o1.vel,o2.vel);
    // 获取法线向量
    var normal = getNormal(o1.pos,o2.pos);
    // 计算相对于法线的相对速度
    var velAlongNormal = vec2DotProduct(relativeVelocity,normal);
}
                        

天啊那部分太难解释了233,如果你还有问题的话请一定在comment提问或者直接QQ上找我(qq号在个人介绍里面)

好啦,这里我们可以引入牛顿的反弹定理(名字似乎不对233,但是英语是Newton's Law of Restitution)
$$V' = -V \cdot e$$ 这条法则说,反弹后的相对于法线的相对速度应该是原本的相对速度乘以反弹系数e。这个反弹系数是每个物体独有的。所以我们在圆的属性里面加一下

function Circle(position,radius,mass,velocity,restitution) {
    // 位置
    this.pos = position;
    // 半径
    this.r = radius;
    // 质量
    this.m = mass;
    // 速度
    this.vel = velocity;
    // 反弹系数
    this.res = restitution;
}
                        

我们这里引入一下冲量(impulse)的概念。冲量就是动量差,或更简单一点,就是一个物体在一段时间内收到的力集在一起产生的一个效应。那么一个冲量产生的加速度(acceleration)是 $$Acceleration = \frac{Impulse}{Mass}$$ 而且像之前说,这个冲量应该是朝着我们法线的方向的。所以假如一个物体受到一个大小为j的冲量,那么它的新速度就应该是
$$V' = V + Acceleration = V + \frac{Impulse}{Mass} = V + \frac{j \cdot n}{Mass}$$
注意,A和B收到的冲量应该是相反的,所以他们两个的新速度应该是
$$V'^{A} = V^{A} + Acceleration = V^{A} + \frac{Impulse}{Mass} = V^{A} + \frac{j \times n}{Mass^{A}}$$ $$V'^{B} = V^{B} - Acceleration = V^{B} - \frac{Impulse}{Mass} = V^{B} - \frac{j \times n}{Mass^{B}}$$ Yay,我们离胜利很近了。我们知道他们新的相对速度应该是原来的相对速度和的-e倍,那我们把这个和前面的式子合并一下。在合并之前我们有一个问题,有两个物体的话就应该就会有两个e吧?那咋办
解决方案very easy,就是取这两个e中最小的一个
Okay,那个解决了,我们来合并
$$-(V^{AB} \cdot n) \times e = -((V^{B} - V^{A}) \cdot n) \times e = (V'^{B} - V'^{A}) \cdot n = (V^{B} + \frac{j \times n}{Mass^{B}} - V^{A} + \frac{j \times n}{Mass^{A}}) \cdot n$$ 这个式子里面,我们其实只需要计算这个冲量大小j到底是多少,所以我们来做一些移项
$$-(1 + e) \times ((V^{B} - V^{A}) \cdot n) = (j \times n \cdot n) \times (\frac{1}{Mass^{A}} + \frac{1}{Mass^{B}})$$ $$j = \frac{-(1 + e) \times((V^{B} - V^{A}) \cdot n)}{\frac{1}{Mass^{A}} + \frac{1}{Mass^{B}}}$$ 天啊,千辛万苦以后终于算出来冲量大小j了(我才不告诉你写的时候忽然发现我看的那个资料全是错的,所以自己全部重新推了一遍)
我们直接把这个带回前面算新的速度的公式里吧。整合一下,代码是这个样子的
// 完整的计算碰撞后速度的函数
function resolveCollision(o1,o2) {
    // 计算相对速度
    var relativeVelocity = vec2Subtract(o1.vel,o2.vel);
    // 获取法线向量
    var normal = getNormal(o1.pos,o2.pos);
    // 计算相对于法线的相对速度
    var velAlongNormal = vec2DotProduct(relativeVelocity,normal);
    // 如果两个物体在正在分离的话那么就不需要再施力
    if(velAlongNormal > 0) return;
    // 算反弹系数
    var elasticity = Math.min(o1.res,o2.res);
    // 计算冲量大小
    var j = -(1 + elasticity) * velAlongNormal / ((1 / o1.m) + (1 / o2.m));
    // 冲量大小 * 法线向量 来重新获得冲量本身
    var impulse = vec2MultiplyConst(normal,j);
    // 用我们前面新速度的公式来更新两个物体的速度 (乘以1/mass 相当于除以mass)
    o1.vel = vec2Add(o1.vel,vec2MultiplyConst(impulse,1 / o1.m)); 
    o2.vel = vec2Subtract(o2.vel,vec2MultiplyConst(impulse,1 / o2.m)); 
}
                        

天啊,经过这么多坑爹的数学和物理知识,终于可以让球互相撞了o(╥﹏╥)o。我们稍微汇合一下我们写的全部代码,如下

function vec2(posX,posY) {
    this.x = posX;
    this.y = posY;
}

// 向量相加
function vec2Add(v1,v2) {
    return new vec2(v1.x + v2.x,v1.y + v2.y);
}

// 向量相减
function vec2Subtract(v1,v2) {
    return new vec2(v1.x - v2.x,v1.y - v2.y);
}

// 向量点积
function vec2DotProduct(v1,v2) {
    return v1.x * v2.x + v1.y * v2.y;
}

// 向量乘以常数
function vec2MultiplyConst(v,c) {
    return new vec2(v.x * c,v.y * c);
}

// 圆物体
function Circle(position,radius,mass,velocity,restitution) {
    // 位置
    this.pos = position;
    // 半径
    this.r = radius;
    // 质量
    this.m = mass;
    // 速度
    this.vel = velocity;
    // 反弹系数
    this.res = restitution;
}

Circle.prototype.show = function() {
    // 把颜色设置成白色
    fill(255)
    // 在pos的地方画一个直径为2*r的圆
    ellipse(this.pos.x,this.pos.y,2 * this.r,2 * this.r);
}

Circle.prototype.act = function() {
    this.pos.x += this.vel.x;
    this.pos.y += this.vel.y;
}

function vec2Dist(o1,o2) {
    // 简单的勾股定理来算两点距离
    return Math.sqrt((o1.x - o2.x) * (o1.x - o2.x) + (o1.y - o2.y) * (o1.y - o2.y));
}

// 计算法线向量
function getNormal(v1,v2) {
    // 计算法线长度
    var leng = vec2Dist(v1,v2);
    // 计算法线
    var Normal = vec2Subtract(v1,v2);
    // 法线除以法线长度
    return new vec2(Normal.x / leng,Normal.y / leng);
}

// 完整的计算碰撞后速度的函数
function resolveCollision(o1,o2) {
    // 计算相对速度
    var relativeVelocity = vec2Subtract(o1.vel,o2.vel);
    // 获取法线向量
    var normal = getNormal(o1.pos,o2.pos);
    // 计算相对于法线的相对速度
    var velAlongNormal = vec2DotProduct(relativeVelocity,normal);
    // 如果两个物体在正在分离的话那么就不需要再施力
    if(velAlongNormal > 0) return;
    // 算反弹系数
    var elasticity = Math.min(o1.res,o2.res);
    // 计算冲量大小
    var j = -(1 + elasticity) * velAlongNormal / ((1 / o1.m) + (1 / o2.m));
    // 冲量大小 * 法线向量 来重新获得冲量本身
    var impulse = vec2MultiplyConst(normal,j);
    // 用我们前面新速度的公式来更新两个物体的速度 (乘以1/mass 相当于除以mass)
    o1.vel = vec2Add(o1.vel,vec2MultiplyConst(impulse,1 / o1.m)); 
    o2.vel = vec2Subtract(o2.vel,vec2MultiplyConst(impulse,1 / o2.m)); 
}

// 检查两个圆是否碰撞了
function checkCollision(c1,c2) {
    if(vec2Dist(c1.pos,c2.pos) <= (c1.r + c2.r)) {
        return true;
    }
    return false;
}

// 位置,半径,质量,速度,反弹系数
var circle1 = new Circle(new vec2(200,50),40,10,new vec2(0.5,0.5),0.8);
var circle2 = new Circle(new vec2(350,50),40,20,new vec2(-1,0.5),0.8);

function setup() {
    // 创建一个450x300的画布
    createCanvas(450,300);
}

function draw() {
    // 把背景变成灰色
    background(51);
    // 圆在这一帧里面要做的东西
    circle1.act();
    circle2.act();
    // 假如两个圆碰撞了,那么就施加相应的冲量
    if(checkCollision(circle1,circle2)) {
        resolveCollision(circle1,circle2);
    }
    // 把圆显示出来
    circle1.show();
    circle2.show();
}
                        

结果如下

(点击画幕来重放模拟)


总结:
天啊,终于全部写完了,看完这个是不是觉得让两个圆撞一下真的神烦(感觉自己又要跳坑了233)。如果你看懂了,恭喜!我自己花了好久时间才完全搞懂o(* ̄︶ ̄*)o。但是记住,这只是最最最最基本的情况。最基本的图形,圆,做基本的相撞。我们还需要
1. 其他图形: 矩形,自定义多边形
2. 旋转: 现在我们只考虑到他们的位置,但是他们现在连旋转都转不了
3. 组合图形: 组合图形的碰撞
4. 其他物理规则: 引力,摩擦力,各种
5. 各种优化: 现在我们只有两个物体,但是如果多了的话,按照现在的方法会卡死的

好啦,我写完这个真的要累死了233。不过下一篇会比这一篇应该会稍微快一些写出来。应该也是有关物理引擎的(我后面还会讲到3D的各种引擎,甚至渲染)。
那这么多啦!
如果你还有问题的话请一定提问哦!Pls Comment And Share ~



CodeTiger
2020.5.15