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

磁力/引力模拟

弹力磁力引力其实本质上很接近。

之所以说相近,是因为他们都是一种长距离作用力

弹簧在被压缩的状况下会产生扩张的力量,而在拉长的状况下则会进行收缩;

引力是指当两个具有质量的东西存在於同一空间时,他们会有互相吸引的力量,这个力量也会跟距离呈负相关;

最後磁力则是大家都知道的同极相斥、异极相吸


其实磁力/引力的部分我打算只用一个案例进行说明,毕竟性质相像。

下图是这次案例的示意图:

img

首先,我们在这次的案例中可以用滑鼠移动去操作一个磁铁磁铁本身的磁力具有最大可影响范围,也就是我们在图片中提到的磁力圈,而在空间中我们会摆放几颗静止的小铁球,当滑鼠靠近小铁球的时候,他们就会被吸进去磁力圈内部。

大致上是这样的概念。

简单录了一段实作的影片:

Github Page: https://mizok.github.io/ithelp2021/magnet-animation.html

这个案例其实不怎麽难,我们马上就来试试看:

import { Vector2D } from '../class'

const CANVAS = {
  width: 800,
  height: 600,
  background: 'gray'
}

const BALLS = [
  {
    x: 300,
    y: 300,
    radius: 25,
    mass: 50,
  },
  {
    x: 380,
    y: 380,
    radius: 25,
    mass: 50,
  },
  {
    x: 375,
    y: 330,
    radius: 25,
    mass: 50,
  },
  {
    x: 200,
    y: 200,
    radius: 55,
    mass: 100,
  },
  {
    x: 250,
    y: 250,
    radius: 15,
    mass: 50,
  },
  {
    x: 450,
    y: 450,
    radius: 10,
    mass: 10,
  },
  {
    x: 600,
    y: 600,
    radius: 75,
    mass: 50,
  },
]

const getDist = (x0, y0, x1, y1) => {
  return Math.sqrt((x1 - x0) * (x1 - x0) + (y1 - y0) * (y1 - y0));
}

const MAGNET_SIZE = 500;

const MAGNET_FORCE_CONST = 7000;

class Circle {
  constructor(x, y, radius, fillColor = 'transparent', strokeColor = 'black', lineWidth = 1) {
    this.x = x;
    this.y = y;
    this.fillColor = fillColor;
    this.strokeColor = strokeColor;
    this.lineWidth = lineWidth;
    this.radius = radius;
  }
  draw(ctx) {
    ctx.save()
    ctx.fillStyle = this.fillColor;
    ctx.strokeStyle = this.strokeColor;
    ctx.lineWidth = this.lineWidth;
    ctx.beginPath();
    ctx.arc(this.x, this.y, this.radius, 0, Math.PI * 2, true);
    ctx.closePath();
    ctx.fill();
    ctx.stroke();
    ctx.restore();
  }
}

class Ball extends Circle {
  constructor(x, y, radius, mass, fillColor = 'rgba(0,0,0,0.25)', strokeColor = 'transparent') {
    super(x, y, radius, fillColor, strokeColor);
    this.friction = 0.995;
    this.force = new Vector2D(0, 0);
    this.acc = new Vector2D(0, 0);
    this.velocity = new Vector2D(0, 0);
    this.mass = mass;
  }
  refreshLocation(dt) {
    this.x += this.velocity.x * dt;
    this.y += this.velocity.y * dt;
  }
  refreshSpeed(dt) {
    this.velocity.scaleBy(this.friction);
    this.velocity.incrementBy(this.acc.multiply(dt));
  }
}

class MagnetAnimation {
  constructor(ctx) {
    this.ctx = ctx;
    this.cvs = ctx.canvas;
    this.frameIsPaused = false;
    this.balls = [];
    this.mouse = {
      x: 0,
      y: 0
    }
    this.magnet = null
    this.init();
  }
  init() {
    this.time = performance.now();
    this.setCanvasSize();
    this.initEvents();
    this.initBalls();
    this.animate();
  }
  initBalls() {
    BALLS.forEach((o, i) => {
      const ball = new Ball(o.x, o.y, o.radius, o.mass);
      this.balls.push(ball);
    })
  }
  initEvents() {
    this.initVisibilityChangeEvent();
    this.initMouseEvent();
  }
  initVisibilityChangeEvent() {
    window.addEventListener('visibilitychange', () => {
      if (document.visibilityState !== "visible") {
        this.frameIsPaused = true;
      }
      else {
        this.frameIsPaused = false;
        this.time = performance.now();
      }
    });
  }
  initMouseEvent() {
    this.cvs.addEventListener('mousedown', () => {
      this.isClicked = true;
    })
    this.cvs.addEventListener('mousemove', (e) => {
      if (!this.isClicked) return;
      let rect = this.cvs.getBoundingClientRect();
      this.mouse.x = e.clientX - rect.left;
      this.mouse.y = e.clientY - rect.top;
    })
    this.cvs.addEventListener('mouseup', () => {
      console.log(this.balls);
      this.isClicked = false;
    })
    this.cvs.addEventListener('mouseleave', () => {
      this.isClicked = false;
    })
  }

  drawAll() {
    this.drawMagnet();
    this.drawBalls();
  }

  drawMagnet() {
    new Circle(this.mouse.x, this.mouse.y, MAGNET_SIZE / 2).draw(this.ctx);
  }

  drawBalls() {
    this.balls.forEach((o, i) => {
      o.draw(this.ctx);
    })
  }

  animate() {
    if (this.frameIsPaused) {
      this.animate();
    }
    const $this = this;
    const frameDelay = 10 // frameDelay 是用来做动画抽帧的常数,可以想像成会让动画加速!
    const dt = (performance.now() - this.time) * frameDelay / 1000;
    this.ctx.clearRect(0, 0, this.cvs.width, this.cvs.height);
    this.drawAll();
    this.refreshBallsLocation(dt);
    this.refreshBallsSpeed(dt);
    this.refreshBallsAcc();

    this.time = performance.now();
    requestAnimationFrame(this.animate.bind($this));
  }

  refreshBallsLocation(dt) {
    this.balls.forEach((o, i) => {
      o.refreshLocation(dt);
    })
  }

  refreshBallsSpeed(dt) {
    this.balls.forEach((o, i) => {
      o.refreshSpeed(dt);
    })
  }

  refreshBallsAcc() {
    this.balls.forEach((o, i) => {
      const distToMouse = getDist(this.mouse.x, this.mouse.y, o.x, o.y);
      if (distToMouse < MAGNET_SIZE / 2 + o.radius && distToMouse > 1e-2) {
        o.force = new Vector2D(this.mouse.x - o.x, this.mouse.y - o.y).para(MAGNET_FORCE_CONST / (distToMouse));
        o.acc = o.force.multiply(1 / o.mass);
      }
      else {
        o.force = new Vector2D(0, 0);
        o.acc = new Vector2D(0, 0);
      }
    })
  }

  setCanvasSize() {
    this.cvs.width = CANVAS.width;
    this.cvs.height = CANVAS.height;
    this.cvs.style.backgroundColor = CANVAS.background;
  }
}


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

比较需要解释的应该也就是受力计算的阶段,我在这个部分使用的是 MAGNET_FORCE_CONST / (distToMouse) 去做磁力的运算。

在高中时期我们学到的公式其实长这样:

img

图片来自 <基本电学 - 台科大图书股份有限公司出版>

在上图中指出,磁力在现实生活中其实是和距离的平方反比的,但是其实很多时候我们去看canvas物理模拟案例,会发现运算的过程似乎跟理论不太一样(我们是取与距离成反比的运算方法)。

通常这种情形有几个原因

  • 我们做的是动画,而不是实验。动画追求的是戏剧效果而不是物理上的正确性
  • 要达成实际理论的计算可能会耗费较多的浏览器资源,所以需要作出取舍

当然我们这边也可以改成『取与距离平方成反比』来做计算,但是我只是想要藉着这个机会提到这个问题。

在越复杂的案例其实越常有这种事发生,像是若实际理论中有大量开根号大量巢状回圈的计算需求,可能就会因为需要减免资源消耗,而采用结果接近,但细节有差异的运算过程。

有时候看源码反而需要花更多时间去理解运算的过程,不能完全依赖书上写的公式。

以上就是本次磁力/引力案例的实作~希望大家喜欢 :D


<<:  # Day 26 Page migration (一)

>>:  Day20 - this&Object Prototypes Ch3 Objects - Review 开头

iphone repair online course

How to choose the best mobile repair training inst...

AE霓虹灯练习2-Day17

中秋连假也要练习,希望大家也可以努力撑下去,剩13天了! 接续昨天的练习~ 1.还可以调整灯管的颜色...

Angular ng-container 与 ng-template

接续昨天的范例。 今天要聊的是 ng-container 与 ng-template <ng-...

[Day15] 碰撞侦测 - 分离轴原理 SAT

今日目标 实作SAT碰撞侦测 SAT的作法 回顾一下,AABB的作法是不管是甚麽形状,都把物件包进矩...

【Day 11】Google Apps Script - API 篇 - 转换流程架构与相关服务

介绍 Docs 转 Api Blueprint 的整体流程架构与相关服务。 今日要点: 》API篇...