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

作为物理模拟开场的第一进程,当然就要来讲一下最经典的物理模拟案例:『弹跳球』~
其实很多国外的Canvas特效教程都会把这一篇当成第一个介绍案例,比方说

这边推荐一下 Apress Physics for JavaScript Games Animation and Simulations 这本书,因为在学习物理模拟的路上这本书给了我不少帮助XD~

在这个案例中我们除了会介绍弹跳球的案例,还会介绍一些关於这个案例的基础物理常识,最後还会带到一些更进阶的物理模拟实作。

在一开始我们还不会马上的带到程序源码,而是要先来讨论高中数理的向量反射斜向抛射,由於我们在这个案例中会持续用到的三个基础概念,所以我打算在一开始就讲清楚物理模拟在这三部分的相关概念。

我们在这篇文章中会先讨论到Canvas向量类的建立,就让我们接着开始吧~

向量是什麽样的概念?

我们其实在前面的文章有提过向量,向量指的是一种从座标A移动到座标B的附带方向的移动量,从数学的角度上来看,假设今天有一个质点即将从(1,2)移动到(2,4),则我们可说这个质点被附加了一个(1,2)的移动向量。

向量如果要转变成纯量,那麽就必须要取该向量X,Y值的平方和,然後再开根号(毕氏定理),以我们刚刚提到的(1,2),他的纯量就是√5(也就是该质点一共移动了√5的距离长度)。

向量再转变成纯量的过程中会丢失他的方向属性,而变成单纯的量值,所以如果今天换成另外一个案例,假设我们只知道移动的距离是√5而不知道这个移动的起始点和结束点; 想要把√5这个距离转变成向量(也就是要知道水平和垂直移动的距离),那我们就必须要先获知该纯量的方向(也就是下图中的角度θ),然後用三角函数来把√5转变成1(水平移动量)和2(垂直移动量)。

img

(cosθ * √5, sinθ * √5) = (1,2)

除了向量变纯量, 纯量变向量的运算以外,向量之间有其他类型的运算,像是:

  • 相加/相减

以下面这张图为例,我们可以可以把紫色向量看作是向量a(红色向量)和向量b(蓝色向量)的和。
所以反过来也可以推导紫色向量 - 向量a = 向量b

  • 内积

内积是一个有趣的概念,求取两个向量内积的方法如下:

假设向量a为(ax,ay),向量b则是(bx,by)
则向量a与向量b的内积是ax*bx+ay*by

内积的结果会是一个纯量,他的几何意义在於我们可以透过内积取得两个向量的夹角。
透过内积取得夹角的公式如下:

img

一般来说,内积的值大於0,代表两向量夹角低於90度
内积的值等於0,代表两个向量互相垂直
内积的值小於0,代表两个向量夹角介於90度到180度之间。

对公式推导有兴趣的人可以看这边

用javascript建立向量类(Vector Class)

在前端开发的环境下,我们其实可以利用ES6的class(当然也可以用ES5的构筑式)去给向量建立一个独立的类。

class Vector2D {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  /**
   * 求纯量值
   *
   * @returns
   * @memberof Vector2D
   */
  length() {
    return Math.sqrt(this.lengthSquared());
  }
  /**
   * 复制该向量
   *
   * @returns
   * @memberof Vector2D
   */
  clone() {
    return new Vector2D(this.x, this.y);
  }
  /**
   *倒转该向量
   *
   * @memberof Vector2D
   */
  negate() {
    this.x = - this.x;
    this.y = - this.y;
  }

  /**
   * 把该向量转变成单位向量
   *
   * @returns
   * @memberof Vector2D
   */
  normalize() {
    let length = this.length(); if (length > 0) {
      this.x /= length;
      this.y /= length;
    }
    return this.length();
  }

  /**
   * 回传与某向量的向量和
   *
   * @param {*} vec
   * @returns
   * @memberof Vector2D
   */
  add(vec) {
    return new Vector2D(this.x + vec.x, this.y + vec.y);
  }

  /**
   * 加上某向量
   *
   * @param {*} vec
   * @memberof Vector2D
   */
  incrementBy(vec) {
    this.x += vec.x;
    this.y += vec.y;
  }

  /**
   * 
   * 回传与某向量的向量差
   * @param {*} vec
   * @returns
   * @memberof Vector2D
   */
  subtract(vec) {
    return new Vector2D(this.x - vec.x, this.y - vec.y);
  }

  /**
   * 扣除某向量
   *
   * @param {*} vec
   * @memberof Vector2D
   */
  decrementBy(vec) {
    this.x -= vec.x;
    this.y -= vec.y;
  }


  /**
     * 回传扩增k倍後的向量
     *
     * @param {*} k
     * @memberof Vector2D
     */
  multiply(k) {
    return new Vector2D(k * this.x, k * this.y);
  }

  /**
   * 扩增该向量
   *
   * @param {*} k
   * @memberof Vector2D
   */
  scaleBy(k) {
    this.x *= k; this.y *= k;
  }


  /**
   * 求取该向量与其他向量的内积
   *
   * @param {*} vec
   * @returns
   * @memberof Vector2D
   */
  dotProduct(vec) {
    return this.x * vec.x + this.y * vec.y;
  }

  /**
   * 求取此向量映射在某向量上的长度
   *
   * @param {*} vec
   * @returns
   * @memberof Vector2D
   */
  projection(vec) {
    const length = this.length();
    const lengthVec = vec.length();
    let proj;
    if ((length == 0) || (lengthVec == 0)) {
      proj = 0;
    } else {
      proj = (this.x * vec.x + this.y * vec.y) / lengthVec;
    }
    return proj;
  }

  /**
   * 回传一个新向量,新向量的方向会跟作为参数向量相同,但是量值上是作为此向量投射在参数向量上的长度
   *
   * @param {*} vec
   * @returns
   * @memberof Vector2D
   */
  project(vec) {
    return vec.para(this.projection(vec));
  }
  
  
  /**
   * 回传垂直与此向量的u倍单位向量
   *
   * @param {*} vec
   * @returns
   * @memberof Vector2D
   */
  perp(u,anticlockwise = true){
		if (typeof(anticlockwise)==='undefined') anticlockwise = true;
		var length = this.length();
		var vec = new Vector2D(this.y, -this.x);
		if (length > 0) {
			if (anticlockwise){ 
				vec.scaleBy(u/length);
			}else{
				vec.scaleBy(-u/length);				
			}
		}else{
			vec = new Vector2D(0,0);
		}	
		return vec;
	}


/**
   * 根据传入的u值来回传一个u倍(或-u倍)的单位向量
   *
   * @param {*} vec
   * @returns
   * @memberof Vector2D
   */
  para(u, positive = true) {

    const length = this.length();
    const vec = new Vector2D(this.x, this.y);
    if (positive) {
      vec.scaleBy(u / length);
    } else {
      vec.scaleBy(-u / length);
    }
    return vec;
  }

  /**
   * 求取该向量与其他向量的夹角
   *
   * @param {*} vec
   * @returns
   * @memberof Vector2D
   */
  static angleBetween(vec1, vec2) {
    return Math.acos(vec1.dotProduct(vec2) / (vec1.length() * vec2.length()));
  }
  
  
  

}

这边我其实是参照Apress Physics for JavaScript Games Animation and Simulations, With HTML5 Canvas 上的写法,改写成ES6 Class,并删除部分不常用到的方法。

我们在接下来的文章中会持续的用到由这边建立好的向量类,所以各位同学可以看一下这个类里面都有些什麽方法~

下一篇文我们将会讲到如何在Canvas中实作反射(Reflection)行为,敬请期待~


<<:  Day 11 用 Context 来组织你的测试区块

>>:  Day 20 Compose UI Animation II (change color & gradient)

[从0到1] C#小乳牛 练成基础程序逻辑 Day 2 - Visual Studio 2022 开发环境建立 64位元

VS 2022 Preview | 64位元 | Browser IDE 🐄点此填写今日份随堂测验 ...

刷题後的归纳与淬链 - 常见的解题技巧「模板」

为什麽要刷题? 在经历过了二十天左右的刷题练习经验,我们从不同的题目中尝试了各种有趣的程序题目。 ...

新增表单/编辑表单,共用?或分开?

目前我们写好了一个新增的画面 需求 接下来,常见的需求是,人员的新增之後是人员的编辑。 新增用的画面...

10. CI x Github Action

CI 持续整合。 为什麽要 CI 呢? 想想我们前面写了那麽辛苦的自动测试,结果有人不跑测试就上传。...

[Golang]变数重声明与重名变数

一、整理变数重声明与重名变数的描述。 变数重声明,对已经声明过的变数,再次声明。 前提条件如下: 变...