Day14 - 物理模拟篇 - 弹跳球世界IV(补完篇) - 成为Canvas Ninja ~ 理解2D渲染的精髓

没错~我就硬是不要给把标题打成『弹跳球世界V』,咬我啊~

这篇是斜面碰撞後篇~

今天要来补完我们在上一篇没有解决掉的reposition反射速度运算的部分。

之前提到的我会把源码更新在Github Repo上面,大家可以在这边自行clone :D

然後这里是repo的Github Page

我简单录了一段实际play的画面:

虽然说前面有讲解过原理,但是这边我们是稍微讲解一些源码中的细节:

这边把之前的图重新贴一次,让大家能更方便的对照程序中向量运算的规则。

img

斜面碰撞主要的核心基本上还是在animateBall这个在每圈RAF循环(我们把requestAnimationFrame的递回操作称为RAF循环)中会被触发的方法,而最重要的计算则是在checkBoundary(),也就是碰撞侦测反弹计算的部分。

整个checkBoundary的流程有4大重点:

  1. 球的碰撞侦测, 也就是前面提到的两大条件(这部分就是我们在上一篇做的)
    1.球和反射面的距离低於球的半径
    2.球和反射面两端点的向量投影长度(投影在反射面上的长度)都低於反射面的长度
  2. 给越过墙壁的球做我们前面提到的reposition操作
    img
  3. 重新计算reposition後的速度,这边是用我们在前面提到的用微积分求得的公式
    v'/v ≒ 1- (a加速度向量・h向量)/ v平方
  4. 计算反射後的速度向量,这边也是一样可以利用我们在前面讲的向量关系去做计算。
    img
normalVelocity = dist.para(ball.velocity.projection(dist),-1);
// 也就是把d向量的单位向量,去以『球速在d向量上的投影长度』为倍数做扩增,接着再颠倒过来
tangentVelocity = ball.velocity.subtract(normalVelocity);
//像我们之前说用『扣除』的方式去取得沿反射面方向的速度分量
// 最後把这两个分量加起来,就会得到反射後的速度向量

源码部分

这边我把checkBoundary()的源码也贴上来,方便大家对照

 checkBoundary(dt) {
    // 这边就是碰撞侦测(第一重点)
    this.boundary.walls.forEach((o, i) => {
      const vectorAB = new Vector2D(
        o[1].x - o[0].x,
        o[1].y - o[0].y
      )
      this.balls.forEach((ball, index) => {
        const vectorAToBall = new Vector2D(
          ball.x - o[0].x,
          ball.y - o[0].y
        );
        const vectorBToBall = new Vector2D(
          ball.x - o[1].x,
          ball.y - o[1].y
        );

        const vectorAToBallProj = vectorAToBall.project(vectorAB);
        const vectorBToBallProj = vectorBToBall.project(vectorAB);
        const distVector = vectorAToBall.subtract(vectorAToBallProj);
        const dist = distVector.length();
        const collisionDetection =
          dist < ball.radius &&
          vectorAToBallProj.length() < vectorAB.length() &&
          vectorBToBallProj.length() < vectorAB.length();
        if (collisionDetection) {
          // 这边是要先做reposition的部分(第二重点)
          // 这边的算法是利用『插进墙壁後的球到墙壁距离+球的半径 = (球在正确的碰撞点到已经插进墙壁这一帧的实际距离, 也就是deltaS)*sin(90度 - 入射角)』

          // 这边一连串的动作是要把deltaS从纯量转换成向量,以便用substract方法去把球的位置倒回去正确的碰撞点

          // perp 是墙壁的单位法向量
          const perp = vectorAB.perp(1);

          // 这边因为单位法向量的n必须要跟球的来向大致相反(也就是向量要夹超过90度),而perp本身又没有办法确定到底是取到正或反的法向量,所以要补一个防呆
          if (perp.dotProduct(ball.velocity) > 0) {
            perp.scaleBy(-1)
          }

          // 球速向量和墙壁的夹角
          const angle = Vector2D.angleBetween(ball.velocity, vectorAB);

          // 我们可以藉由算 (球半径+球到墙壁距离向量和墙壁法向量的内积)/sin(球速与墙壁夹角) 来取得deltaS

          const deltaS = (ball.radius + distVector.dotProduct(perp)) / Math.sin(angle);

          // 把球速转化成单位向量,接着再扩张deltaS倍,这样就能取的到底要倒回去多少距离才能来到正确的碰撞点
          let displ = ball.velocity.para(deltaS);
          ball.x -= displ.x * dt;
          ball.y -= displ.y * dt;
          //到这边就reposition完毕~

          // 这边的vcor是我们之前有提到过,加速度和帧间误差的相互关系会导致球被额外加速一小段距离,而这边可以藉由乘以vcor 这个参数来抵销多余的加速量(第三重点)
          var vcor = 1 - ball.gravity.dotProduct(displ.multiply(dt)) / ball.velocity.lengthSquared();
          // 原速度乘以vcor
          var Velo = ball.velocity.multiply(vcor);

          // 这边就是取球速於墙壁法线方向的分量
          var normalVelo = distVector.para(Velo.projection(distVector));
          // 这边则是取球速平行於墙壁的分量
          var tangentVelo = Velo.subtract(normalVelo);
          // 两者合并就是反射後的速度
          ball.velocity = tangentVelo.addScaled(normalVelo, -ball.friction);

        }
      })

    })
  }

我发现好像球还是有低机率产生异常的穿墙行为?

穿墙的bug之所以会发生,是因为动画是frame by frame的。

有时候球在接近墙壁时,在这一帧还没碰到墙壁,但是在下一帧却已经完全穿越过墙壁了,这就导致碰撞侦测完全没有被触发。

延伸阅读:避免子弹穿越墙壁

其实这个现象在物理模拟的领域有一个正式的名称: 穿隧效应(Tunneling Effect)

符合下列条件的情形,穿隧效应发生的机率会比较高:

  • 球速非常快
  • 球的半径非常小
  • 墙板不够厚

其实物理模拟的碰撞侦测是一种很深的学问,而我在这个案例里面用的方法是所谓的『离散式碰撞侦测(Discrete collision detection)』,通常如果要实现高速物体的碰撞侦测,要使用『连续式碰撞侦测(Continuous collision detection (CCD) )』。

连续式碰撞侦测的原理是依靠预先演算物体未来的前进轨迹,侦测其前进轨迹上是否有障碍物,计算出可能发生的碰撞,还有碰撞的时间点碰撞位置,然後准确地在那个时间点去把物体移位到指定的座标,避免穿隧效应发生。

不过很遗憾,在这个系列我其实没有打算把这个领域的知识扩展到这麽深..
(因为那样可能就要把整个铁人赛的内容给砸下去了...)
但是如果真的要在前端环境实现CCD ,其实可以依赖一些现有的物理引擎,例如

以上五篇,包含今天的内容~就是弹跳球动画的实作 :D ~

如果对程序原理/过程中有任何的疑问,也希望可以在留言区提出~ 我会尽可能地做解答!


<<:  Day16_ISO 27701 - 个人资料隐私资讯管理系统(隐私资讯管理体系(PIMS))

>>:  [ Day 14 ] - 存取网页元素之变更指定内容

Vue.js 从零开始:watch

watch监听器 监听data里面的值,当值有变化时,就会触发事件。 watch监听一个变数: &l...

21 - Traces - 观察应用程序的效能瓶颈 (5/6) - 透过 APM Agents 收集并传送後端服务运作的记录

Traces - 观察应用程序的效能瓶颈 系列文章 (1/6) - Elastic APM 基本介绍...

ETA screen testing (2)

上一篇我们写了一些 EtaViewModel 的测试,这一篇会集中写跟时间相关的测试。 之前在 Et...

Day21-State

前言 昨日我们学会了Props,而Props是由外传进来的资料,进而改变组件。而组件本身的状态我们称...

2D Transform

大家好,我是西瓜,你现在看到的是 2021 iThome 铁人赛『如何在网页中绘制 3D 场景?从 ...