Day12 - 物理模拟篇 - 弹跳球世界III - 成为Canvas Ninja ~ 理解2D渲染的精髓

我们在上一次讲到用数理观点来观察反射行为的诸多细节,而这篇文则是要讲解斜向抛射
不过因为斜向抛射的概念其实不是挺复杂,所以为了不要浪费篇幅,我也会先把一些程序的实作面先在这一篇做一个简单的引导~

斜向抛射

斜向抛射这个名词,我想应该对很多人来说都有很深的印象。(想当年刚成为理组小菜鸡的我在物理课上到这一段的时候真的有三观被刷新的感觉XD)

斜向抛射 顾名思义就是朝某个角度丢出一个球的意思,而丢出去的球会在过程中持续地受到重力的影响,导致它会有每秒向下1g的加速度。

img

我们之前有介绍过向量可以分裂成垂直向量水平向量,而如果我们观察被斜向抛射出去的球,他的速度其实也可以被拆成垂直水平的部分。在水平的部分,因为水平方向上没有受力(我们假设环境中没有风力或摩擦力),所以水平方向的速度会维持不变,但是垂直方向就不同了,因为垂直方向会受到重力影响,而假设球一开始是往上丢的,那麽球在上升的过程中,上升的速度就会越变越慢,直到达到最高点,接着就会开始往下加速,最後在落地前一刻,他会达到跟刚被丢出去一样的垂直速度(但是方向是相反的)。

若用数学来描述垂直水平的速度向量,则可以这样表示:

// θ 是抛射出去的仰角,t是经过时间
horizontalVelocity = v*cos(θ)  // 水平速度
horizontalVelocity = v*sin(θ) - gt // 垂直速度 

好啦~ 斜向抛射 就是这麽简单,没什麽特别的。接下来我们会直接带一个简单的弹跳球范例来作为热身用~

来看看最简单的弹跳球范例吧

这边我们先来看一个没有倾斜面的范例

其实这个例子在MDN上面也有类似的版本。

延伸阅读:MDN弹跳球

但是MDN上的版本其实简化了很多东西,例如没有做碰撞的Reposition,除此之外加速度的模拟也没有透过侦测经过时间来给予速度加乘,而是变成每一帧加一点点速度。

MDN 之所以可以不用作Reposition透过侦测经过时间来给予速度加乘,是因为他的速度设定的很慢,所以就算真的产生帧间误差,也不会出现很诡异的状况。

通常如果物体运动的速度很快,但是又没有做Reposition,反弹动画依据程序的写法差异会有两种异常现象:

  • 球卡进墙壁里面无限反弹出不来 (最主要就是要防范这种情形)
  • 球不在墙壁上反弹,而是在空无一物的地方被弹飞

而加速度的部分,他有给一个0.99浮点数作为摩擦系数,这让球不会在碰撞多次之後产生异常状况。

那麽这边我们就来提出自己的版本~


const DEFAULT = {
  radius: 40,
  color: 'red',
  speedX: 1000,
  speedY: 30,
  accelerationX: 0,
  accelerationY: 980,
  frictionX: 1,
  frictionY: 0.999,
}

class BasicRefelection {
  constructor(ctx, config) {
    this.ctx = ctx;
    this.time = 0;
    this.cvs = ctx.canvas;
    this.config = config;
    this.init();

  }
  init() {
    this.initBall();
    this.time = performance.now();
    this.animateBall();
    let $this = this;
    
    // 绑定visibilitychange事件
    window.addEventListener('visibilitychange', () => {
       
      if (document.visibilityState !== "visible") {
        $this.frameIsPaused = true;
      }
      else{
        $this.frameIsPaused = false;
        $this.time = performance.now();
      }
      
     
    });
  }
  initBall() {
    let $this = this;
    this.ball = {
      color: $this.config.color,
      radius: $this.config.radius,
      location: {
        x: $this.cvs.width / 2,
        y: $this.cvs.height / 2,
      },
      speed: {
        x: $this.config.speedX,
        y: $this.config.speedY
      },
      acceleration: {
        x: $this.config.accelerationX,
        y: $this.config.accelerationY
      },
      friction: {
        x: $this.config.frictionX,
        y: $this.config.frictionY
      }
    }
  }
  drawBall() {
    this.drawCircle(this.ball.location.x, this.ball.location.y, this.ball.radius * 2, this.ball.color);
  }
  animateBall() {
    let $this = this;
     // 当画面没有被暂停(页签停在这页)
      if(!$this.frameIsPaused){
        $this.ctx.clearRect(0, 0, $this.cvs.width, $this.cvs.height);
      // 画球
      $this.drawBall();
      // 更新位置
      $this.refreshLocation();
      // 更新速度
      $this.refreshSpeed();
      // 检查碰撞行为,确定是否倒转向量
      $this.checkBoundary();
      // 更新纪录时间
      $this.time = performance.now();
      // 用RAF递回$this.animateBall()
      requestAnimationFrame($this.animateBall.bind($this));
      }
    // 当画面被暂停,就单纯的递回$this.animateBall()
    else{
      $this.animateBall();
    }
      
  }

  refreshSpeed() {
    let dt = (performance.now() - this.time) / 1000;
    this.ball.speed.x = this.ball.speed.x * this.ball.friction.x + this.ball.acceleration.x * dt;
    this.ball.speed.y = this.ball.speed.y * this.ball.friction.y + this.ball.acceleration.y * dt;
  }

  refreshLocation() {
    let dt = (performance.now() - this.time) / 1000;
    
    this.ball.location.x += this.ball.speed.x * dt;
    this.ball.location.y += this.ball.speed.y * dt;
  }
  checkBoundary() {
    let ball = this.ball;
    let canvas = this.cvs;
    // 当球正在底端
    if (ball.location.y + ball.radius > canvas.height) {
      // 且速度为正值(朝下)
      if (ball.speed.y > 0) {
        ball.speed.y = -ball.speed.y;
      }
    }
    // 当球正在顶端
    else if (ball.location.y - ball.radius < 0) {
      // 且速度为负值(朝上)
      if (ball.speed.y < 0) {
        ball.speed.y = -ball.speed.y;
      }
    }

    // 当球正在右端
    if (ball.location.x + ball.radius > canvas.width) {
      if (ball.speed.x > 0) {
        ball.speed.x = -ball.speed.x;
      }
    }
    // 当球正在左端
    else if (ball.location.x - ball.radius < 0) {
      if (ball.speed.x < 0) {
        ball.speed.x = -ball.speed.x;
      }
    }

  }
  drawCircle(x, y, width, color, alpha) {
    let ctx = this.ctx;
    ctx.save()
    ctx.fillStyle = color;
    ctx.globalAlpha = alpha;
    ctx.beginPath();
    ctx.arc(x, y, width / 2, 0, Math.PI * 2, true);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }
}

(()=>{
  let ctx = document.querySelector('canvas').getContext('2d');
  let instance = new BasicRefelection(ctx,DEFAULT)
})()

codepen连结:https://codepen.io/mizok_contest/pen/ZEymVZQ?editors=1010

这边说明一下程序的流程面:

  • class 在创建实例之後会进入入口方法init
  • init 会先创建物件的refernece,物件内会根据输入的参数预设一个初始速度向量,和加速度与颜色/球的大小之类的数值, 而球的初始位置定在Canvas正中央。
  • 接下来就会进入animateBall 开始进行动画
  • animateBall 会分成几个阶段:
    • 先一律清除画布
    • 根据物件中纪录的当前位置大小颜色等画出
    • 更新物件中纪录的位置,更新的方法为旧位置+速度*帧间时差(dt)
    • 更新物件中纪录的瞬时速度,更新的方法为旧速度+加速度*帧间时差(dt)
    • 判断下一帧时,球是否会碰撞到墙壁,若确定会碰撞,则逆转对应的速度分量,这边我们调整了优化了碰撞反弹的判断,在判断反弹时补上一个防呆判定,让球不至於会卡进去墙壁里面出不来,

这边值得一提的是,由於我们在前面有讲过,canvas专案适合使用requestAniamtionFrame(简称RAF)来做动画帧渲染的looping。

但是实际上RAF本身有个特点,就是他只会在页面"visible"时触发。

在这种情况下,因为我们在速度计算上是采用帧间时差制来计算(而不是By Frame),如果我们让页面进入hidden状态(例如切换页签/缩小视窗)然後再切换回visible 状态,球的速度就会因为hidden的时候都没有去刷新this.time(用来记录当下时间的property)而产生暴走的现象。

这边就可以利用window的预设事件visibilitychange 来阻止暴走发生,藉由侦测document.visibilityState来判断是否在切换回visible 时刷新this.time

以上就是弹跳球在没有倾斜面的动画程序范例~

下一篇文我们将会介绍有倾斜面的状况下,程序的写法,敬请期待 :D


<<:  买菸赔菸 - 零股买卖

>>:  # Day 18 Physical Memory Model (三)

14【推坑】考 APCS 升大学大有优势

提完了那麽多有关 APCS 的事,这次想要分析考 APCS 能够有怎样的好处。 权威性: APCS ...

Day21 TensorFlow&OpenCV简介

我的目的 学习图像辨识,顺便拯救专题,再顺便参加铁人赛,一鱼三吃,真香。 图像辨识的原理 简单说就是...

Day18:今天来聊一下使用Microsoft 365 Defender 缓和incidents

Azure Defender提供目的导向的使用者介面,可管理和调查 Microsoft 365 服务...

[资料库] 学习笔记 - case when then 和 预存程序 之2

这次的问题是上一篇文的延伸 上一篇是使用者输入参数後,再将指定栏位的值改为1 那这次是输入参数後检查...

Day 21:专案04 - Facebook爬虫02 | Selenium

昨天结束在Facebook登入之後,今天就接续昨天的内容,以木棉花的粉丝专页为例,来讲怎麽爬下来贴...