Day16 - 物理模拟篇 - 弹力、引力与磁力I - 成为Canvas Ninja ~ 理解2D渲染的精髓

经过一天的休息,我们又再度回到了物理模拟的世界~

我们在这次的chapter要来介绍的是弹力张力引力磁力这种物体之间~与距离相关的作用力的演算方法。

首先我们会先从弹力开始做介绍~

弹力

高中物理课我们有学过:

F = k * deltaX

这个公式的意思是说弹力的量值会跟物体被拉伸/压缩的变形量成正比,k是一个常数(弹力系数),deltaX则是变形量。

弹力的概念其实也就是这麽简单~我们接着来看看一个简单实作在canvas 上的弹力 物理模拟案例~

一维弹力模拟

这个案例就是国高中常常看到的『一颗被连接在一条弹簧上面,而弹簧另外一端连接在上,某A试图去拉动这颗然後放手』。

高中物理还有印象的人应该会知道接下来就会开始进入简谐运动(SHM)的状态

在球运动的过程中,弹簧的变形量其实是会随时间变化的,这也就是说弹力也会呈现波动的状态,而我们知道牛顿第二定理:

F = m * a  // 在 质量m 固定的状况下,受力会和加速度成正比

也就是说加速度也会不断的波动!~

看到这边,大家应该可以大致上理解如果要把这样的案例搬到canvas 上面要怎麽写~

先来个实作的影片:


// 这边我们把碰撞用的Ball Class稍微修正一下拿来用
class Ball {
  constructor(x, y, color , radius ) {
    this.x = x;
    this.y = y;
    this.color = color;
    this.radius = radius
    this.densityConst = 5;
    this.mass = this.radius * this.densityConst;
    this.force = new Vector2D(0,0);
    this.velocity = new Vector2D(0, 0);
    this.acc = new Vector2D(0, 0);
    this.time = 0;
  }

  draw(ctx) {
    ctx.save()
    ctx.fillStyle = this.color;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, true);
    ctx.closePath();
    ctx.fill();
    ctx.restore();
  }
  refreshLocation(dt) {
    this.x += this.velocity.x * dt;
    this.y += this.velocity.y * dt;
  }
  refreshSpeed(dt) {
    this.velocity.scaleBy(0.995);
    this.velocity.incrementBy(this.acc.multiply(dt));
  }

  // 这次可以看到我们新增了这个方法,用来给外部操作更新受力
  refreshForce(force){
    this.force=force;
  }
  // 加速度 = 受力/ 质量, 应该不难理解
  refreshAcc(){
    this.acc = this.force.multiply(1/this.mass);
  }
}
// 主入口
class HorizontalSpring{
  constructor(ctx,springLength,ballRadius){
    this.springLength = springLength;
    this.ballRadius = ballRadius;
    this.ctx = ctx;
    this.cvs = ctx.canvas;
    this.mouse = {
      x:0,
      y:0
    }
    this.time = 0;
    this.init();
  }
  // 入口方法
  init(){
     let x = this.cvs.width/2 - this.springLength  + this.ballRadius;
      let y = this.cvs.height/2;
     this.ball = new Ball(x,y,'red',this.ballRadius);
    this.bindClickEvent();
    this.bindVisibilityChangeMech();
    this.animateBall();
  }
  
  bindVisibilityChangeMech(){
    // 绑定visibilitychange事件
    window.addEventListener('visibilitychange', () => {
       
      if (document.visibilityState !== "visible") {
        this.frameIsPaused = true;
      }
      else{
        this.frameIsPaused = false;
        this.time = performance.now();
      }
      
     
    });
  }
  // 绑定点击事件,当点击後移动滑鼠就可以拖曳球
  bindClickEvent(){
    this.cvs.addEventListener('mousedown',(e)=>{
      let x = e.clientX - this.cvs.getBoundingClientRect().left;
      let y = e.clientY - this.cvs.getBoundingClientRect().top;
      let bias = 20;
      // 这边是利用侦测滑鼠点击的位置是否接近球当下的位置来决定球有没有被点到
      this.ballIsClicked = 
      (this.ball.x+this.ball.radius+bias>=x && this.ball.x-this.ball.radius-bias<x) && (this.ball.y+this.ball.radius+bias>=y &&this.ball.y-this.ball.radius-bias<y);
      
      this.mouse.x = x;
      this.mouse.y = y;
      
    })
    
    this.cvs.addEventListener('mousemove',(e)=>{
      if(this.ballIsClicked){
        let x = e.clientX - this.cvs.getBoundingClientRect().left;
        let y = e.clientY - this.cvs.getBoundingClientRect().top;
        this.mouse.x = x;
        this.mouse.y = y;
      }
    })
    
    this.cvs.addEventListener('mouseup',(e)=>{
      this.ballIsClicked = false;
    })
  }
  
  // 刷新球的位置数据
  updateLocation(dt){
    // 如果滑鼠点击到球时,球会跟着滑鼠一起动
    if(this.ballIsClicked){
      // 另外补一个防呆,让球只能被向左边拉
      if(this.mouse.x < this.cvs.width/2 - this.springLength - this.ballRadius){
          this.ball.x = this.mouse.x;
      }
      
    }
    else{
      
        this.ball.refreshLocation(dt);
      
      
    }
  }
  // 更新球的速度
  updateSpeed(dt){
     this.ball.refreshSpeed(dt);
  }
  
  // 这边就是根据球的位置来决定弹簧变形量,然後再根据变形量求得弹力
  updateForce(){
    let  k  = 10;
    let deltaX = this.cvs.width/2 - this.ball.x + this.ballRadius - this.springLength;
    let force;
    force = new Vector2D( k * deltaX,0)
    
    this.ball.refreshForce(force);
  }
  
  updateAcc(){
    this.ball.refreshAcc();
  }
  // 老样子的animateBall,但是这次多了刷新受力/ 加速度的方法
  animateBall(){
    let $this = this;
    let correct = 100; // 这边这个correct的用途是让我们在一帧内多次『重新计算』摩擦力耗损的现象,达到避开後续帧间误差的情况
    if(!$this.frameIsPaused){
        let dt = (performance.now() - this.time)/1000;
          this.ctx.clearRect(0,0,this.cvs.width,this.cvs.height);
          this.drawSpring();
          this.drawBall();
          for(let i = 0 ;i<correct;i++){
            this.updateLocation(dt);
            this.updateSpeed(dt);
            this.updateForce();
            this.updateAcc();
          }
          this.time = performance.now();
          requestAnimationFrame(this.animateBall.bind($this))
    }
    else{
      this.animateBall();
    }
    
  }
  // 画球
  drawBall(){
     this.ball.draw(this.ctx);
  }
  //画弹簧
  drawSpring(){
    this.ctx.beginPath();
    this.ctx.moveTo(this.cvs.width/2,this.cvs.height/2);
    this.ctx.lineTo(this.ball.x - this.ballRadius,this.cvs.height/2);
    this.ctx.strokeStyle="white";
    this.ctx.lineWidth = 5;
    this.ctx.stroke();
    this.ctx.closePath();
  }
  
}





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

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

像这样就是一个简单的一维弹力模拟

值得一提的是,弹力的模拟其实也会受到帧间误差的影响,为什麽呢?

其实这个问题很简单,我们之前有提到过帧间误差加速度的互动关系,以反弹的例子来讲,球会因为没办法准确地停在『球心到墙壁距离等於半径的位置』,而会有多余的加速行为~其实弹力的案例也是一样,在这个案例中我们可以发现,球在运动的过程中,也同样很难回到变形量为0的那个点,这导致每次弹簧回弹一样也会留下多余的变形量。

这边的解决方法虽然其实也一样可以运用『加上一个摩擦耗损系数来递减速度』来减少加速过度的问题 ~
但是因为帧间误差对於弹力计算的影响其实会比之前我们看过的『球碰撞墙壁时越位的问题』来得多,所以这边我们需要做一个操作,那就是要『强制』摩擦耗损这个行为多次发生。

指的就是源码中这部分:

for(let i = 0 ;i<correct;i++){
            this.updateLocation(dt);
            this.updateSpeed(dt);
            this.updateForce();
            this.updateAcc();
          }

这边我们会透过重复correct次的量值计算,来达到我们的目的。

这其实算是一种小技巧(虽然说物理上不见得正确),但是他却能够达成我们所需的视觉效果。

这一个小技巧能够实现其实有一部分是因为近年浏览器运算的效率提高非常多,可以在一帧内完成很多的运算

我们在後面的案例会持续用到这种做法,来抵销帧间误差带来的影响。

我们在下一篇文将会提到垂直向附带重力状况的弹力运算案例,敬请期待 :D ~


<<:  Day 16 Simple Network Management Protocol (SNMP) 相关安全

>>:  Day16 测试与进度计算,与客诉的关系

Day 27 - 使用 CDK 创建 CloudWatch 也能发送 Alarm 到 LINE 的系统

前几天说了很多建置系统的方法但是对於监控系统没有太多的说明,今天就来一个 CloudWatch 传送...

铁人赛 Day12-- PHP SQL基本语法(七) -- UPDATE & DELETE

前言 昨天有谈到了新增,那今天就来谈谈 更新UPDATE 和 删除DELETE 吧 UPDATE 资...

Day-3 小学数学(bit ver.)

小学数学(bit ver.) tags: IT铁人 例题答案 不知道各位有没有试试看前面的题目呢??...

第34天~还原资料库完.然後~

这篇的上一篇:https://ithelp.ithome.com.tw/articles/10283...

Day 29-给我无限多的预算我就能撑起全世界,infracost 教你吃米知米价

本篇介绍如何使用 infracost 工具估计 infrastructure apply 的花费 课...