球碰撞

337 阅读3分钟

开始

从Canvas开始实现一个2D的球碰撞模型

第一步就是创造球。创建的球需要有大小, 并且具有位置信息。

 interface Ball {
          x: number;
          y: number;
          r: number;
        }
        class Ball implements Ball {
          constructor(x = 10, y = 10, r = 10) {
            this.x = x;
            this.y = y;
            this.r = r;
          }
        }
      

要一个Canvas类来执行绘制等操作。

      interface Canvas {
          $canvas: HTMLCanvasElement;
        }
        class Canvas implements Canvas {
          constructor (canvasElement) {
            this.$canvas = canvasElement;
          }
        
          drawCircle(ball:Ball) {
            const ctx = this.$canvas.getContext("2d");
            ctx?.beginPath();
            ctx?.arc(ball.x, ball.y, ball.r, 0, 2 * Math.PI);
            ctx?.stroke();
          }
        }
      

创建一个Canvas:

        const canvas = new Canvas(el);
        const ball = new Ball(20, 20, 15);
        canvas.drawCircle(ball);

image.png 出现了一个圆圈,为球添加一个颜色属性, 然后把它填充起来

运动的球

接下来就是要让球动起来。所有动画的实现原理基本都相同:快速播放连续的静态画面, 在肉眼中获得连续的效果就是动画。每一个静态画面成为一个帧。只需要设定静态画面的生成规则, 然后去绘制就行。

“运动规则”:这需要一点基础的物理和数学知识。使用向量来描述运动状态。实现一个向量类, 其中包含一些向量的常用方法。

class Vector implements Vectors {
    x: number
    y: number
    constructor(x, y) {
      this.x = x;
      this.y = y;
    }

    add(vector: Vectors): Vectors {
      return new Vector(this.x + vector.x, this.y + vector.y);
    }

    subtract(vector: Vectors): Vectors {
      return new Vector(this.x - vector.x, this.y - vector.y)
    }

    multiply(scalar: number): Vectors {
      return new Vector(this.x * scalar, this.y * scalar);
    }

    dotProduct(vector: Vectors): number {
      return this.x * vector.x + this.y * vector.y;
    }

    get direction() {
      return Math.atan2(this.x, this.y);
    }
}
      

在Ball中新增position属性,用向量表示。当然仅仅有位置是不够的,描述运动还需要更多的属性, 例如速度,所以同时我们新增另一个属性velocity。

class Ball implements Balls {
  x: number
  y: number
  r: number
  position: Vectors
  velocity: Vectors
  color: string
  constructor(x = 10, y = 10, r = 10, color = "red", velocity: Vectors) {
    this.x = x;
    this.y = y;
    this.position = new Vector(x, y);
    this.velocity = velocity;
    this.r = r;
    this.color = color;
  }
}
        

这个构造函数要接收的参数越来越多,为了方便处理, 简化传入一个对象。

class Ball implements Balls {
   x: number = 10
   y: number = 10
   r: number = 10
   position: Vectors = new Vector(this.x, this.y)
   velocity: Vectors | null = null
   color: string = "#FF0000"
    constructor(config: Balls) {
      Object.assign(this, config, {
        position: new Vector(config.x, config.y)
      });
    }
}
        

现在球已经拥有了动画所需的所有属性,接下来就要进行动画的绘制。为了方便管理绘制,笔者准备实现一个控制器, 用来控制画布中的元素更新, Controller控制要显示的画布和其中的运动图形, 控制他们的更新。

class Controller {
  canvas: CanvasI
  elms: Balls[]
  constructor(canvas: CanvasI, elms: Balls[]) {
    this.canvas = canvas;
    this.elms = elms;
  }

  update(timer) {
    /**
     * 为elm生成唯一的更新ID
     */
    const updateId = Math.floor(Math.random() * 1000);
    const elems = this.elms.map((elm)=> {
      return elm.update && elm.update(this, timer, updateId);
    });
    return new Controller(this.canvas, elems)
  }
}
        

controller用于绘制每个元素的状态, 所以我们为每个元素, 也就是ball添加update方法, 用以更新其状态。

class Ball implements Balls {
  /**............. */
  /**
   * 
   * @param ctrl 控制器
   * @param timer 时间
   * @param updateId 更新ID
   */
  update(ctrl: Controllers, timer: number, updateId: number) { 
    //球位置碰触到画布左右边界
    if (this.position.x + this.r >= ctrl.canvas.$canvas.width || this.position.x - this.r <= 0) {
      this.velocity = new Vector(-this.velocity.x, this.velocity.y)
    }
    //球的位置碰触到画布上下边界
    if (this.position.y + this.r >= ctrl.canvas.$canvas.height || this.position.y - this.r <= 0) {
      this.velocity = new Vector(this.velocity.x, -this.velocity.y);
    }

    return Object.assign(this, {position: this.position.add(this.velocity)})
  }
}
        

动画就是一帧一帧的图像绘制, 所以要为canvas提供同步的绘制方法, 以便更新所有元素的位置。

class Canvas implements CanvasI {
  /* ...... */

  sync(balls: Balls[]) {
    this.clearCanvas();
    this.updateSync(balls)
  }

  clearCanvas() {
    const ctx = this.$canvas.getContext('2d');
    ctx?.clearRect(0, 0, this.$canvas.width, this.$canvas.height);
  }

  updateSync(balls:Balls[]) {
    balls.forEach((ball) => this.drawCircle(ball));
  }
}
        

碰撞检测

开始绘制更多球。处理画布的边界情况是简单的, 但是如果多个球之间有相互作用, 就会变得略微复杂。首先要知道不同的球之间是否发生了碰撞。 碰撞检测的原理:判断两个球的圆心之间的距离小于等于两个球的半径之和, 在球的update方法中加入对应地检测代码。

class Ball implements Balls {
  /*.........*/
  /**
   * 
   * @param ctrl 控制器
   * @param timer 时间
   * @param updateId 更新ID
   */
  update(ctrl: Controllers, timer: number, updateId: number) { 
    //球位置碰触到画布左右边界
    if (this.position.x + this.r >= ctrl.canvas.$canvas.width || this.position.x - this.r <= 0) {
      this.velocity = new Vector(-this.velocity.x, this.velocity.y)
    }
    //球的位置碰触到画布上下边界
    if (this.position.y + this.r >= ctrl.canvas.$canvas.height || this.position.y <= 0) {
      this.velocity = new Vector(this.velocity.x, -this.velocity.y);
    }

    //碰撞检测
    ctrl.elms.forEach((item: Balls) => {
      if (item === this) return;

      //俩圆心之间的距离
      const distance = Math.sqrt((this.position.x - item.position.x) ** 2 + (this.position.y - item.position.y) ** 2);
      if (distance <= this.r + item.r) {
        //更换颜色以标识碰撞发生
        this.color = this.color === "#FF0000" ? "#00FF00" : "#FF0000";
        item.color = item.color === "#FF0000" ? "#00FF00" : "#FF0000";
      }
    })

    return Object.assign(this, {position: this.position.add(this.velocity)})
  }
}
          

创建多个球,赋予随机的速度和位置,观察碰撞检测的正确性。

每当两颗球检测到碰撞时, 让球的颜色发生红绿之间的变化。仔细观察能够看出, 获得了正确的碰撞检测结果。

碰撞检测算法: 每次对每个球进行位置更新判断时, 依次和其它球进行比较计算, 从而确定碰撞状态, 这里的时间复杂度是O(n^2), 这在球的个数比较少时没有什么问题, 不过这也意味着随着球的数量增加, 更新所花费的时间将会大幅度增加, 这里有一个性能隐患,有待进一步优化。

添加弹性碰撞

为了简化模型, 使用弹性碰撞,从而避免摩擦力,碰撞损失等问题带来的更高的复杂度。

弹性斜碰满足碰撞方向分动量守恒, 总动量守恒和能量守恒。我们能够直接获得矢量表示的弹性斜碰方程如下:

image.png

尖括号表示向量的内积, 双竖线表示向量的长度。

从上面的公式中能够看到, 碰撞的计算和质量有明显的关系, 因此我们要为ball添加上质量。依旧是为了简化模型, 假设每个球的密度相同且恒定且均匀, 这样我们就可以“直接”用面积来表示质量。

class Ball implements Balls {
  /*....... */
  get weight() {
    return (Math.PI * this.r) ** 2;
  }
}     

然后我们需要将碰撞公式用函数表示出来:

    //球的弹性斜碰公式
    const collision = (ball1: Balls, ball2: Balls) => {
      //圆心位置之差(x1-x2)
      const posSub = ball1.position.subtract(ball2.position);
      //内积
      const dotProduct = ball1.velocity.subtract(ball2.velocity).dotProduct(posSub);
      //向量长度的平方
      const lengthSquare = (ball1.position.x - ball2.position.x) ** 2 + (ball1.position.y - ball2.position.y) ** 2;
      //质量比部分
      const weightRatio = 2 * ball2.weight / (ball1.weight + ball2.weight);
      return ball1.velocity.subtract(posSub.multiply(weightRatio * dotProduct / lengthSquare));
    }
      

接下来就是要在球的update方法中添加碰撞。检测两个球是否碰撞,如果碰撞就变色。将变色部分替换成碰撞公式。这里有一个潜在的问题:两个球碰撞都是针对于碰撞发生瞬间前的运动状态进行处理的, 因此不能单个更新球的运动状态, 因为当开始更新另一个碰撞的球时, 原先那个球的运动状态已经发生了变化, 导致原先的运动状态发生了丢失。所以这里在发生碰撞时, 一同时更新碰撞的两个球的运动状态,然后将他们缓存起来, 每当更新球的运动状态时进行判断, 如果已经更新过球的运动状态, 就不再更新

class Ball implements Balls {
  /*...... */
  collision: Balls[] = [];

  /**
   * 
   * @param ctrl 控制器
   * @param timer 时间
   * @param updateId 更新ID
   */
  update(ctrl: Controllers, timer: number, updateId: number) { 
    //球位置碰触到画布左右边界
    if (this.position.x + this.r >= ctrl.canvas.$canvas.width || this.position.x - this.r <= 0) {
      this.velocity = new Vector(-this.velocity.x, this.velocity.y)
    }
    //球的位置碰触到画布上下边界
    if (this.position.y + this.r >= ctrl.canvas.$canvas.height || this.position.y - this.r <= 0) {
      this.velocity = new Vector(this.velocity.x, -this.velocity.y);
    }

    //碰撞检测
    ctrl.elms.forEach((item: Balls) => {
      if (item === this) return;

      //俩圆心之间的距离
      const distance = Math.sqrt((this.position.x - item.position.x) ** 2 + (this.position.y - item.position.y) ** 2);
      if (distance <= this.r + item.r) {
        //如果已经被目标球记录过, 说明已经在发生碰撞时进行过计算了, 就直接跳过
        if (item.collision.includes(this)) {
          return ;
        }
        const b1Velocity = this.collisionChanged(this, item);
        const b2Velocity = this.collisionChanged(item, this);
        this.velocity = b1Velocity;
        item.velocity = b2Velocity;
        this.collision.push(this, item);
      }
    })

    return Object.assign(this, {position: this.position.add(this.velocity)})
  }
      

问题

两个球的部分重叠在一起,无法分开。运动轨迹也变得奇怪。分析方案, 发生重叠的情况大致下面几种:

    1. 随机生成球的时候发生了重叠。
    1. 当三个球位置很近, 其中一个球和另一个球发生碰撞, 位置更新后与另一个球发生了重叠。
    1. 墙体碰撞更新后位置与另一个球发生了重叠。 把小球的球心看作是一块强磁铁, 当两个小球发生重叠时, 让斥力发挥作用, 改变球体运动轨迹直到不发生重叠, 然后移除斥力作用。

球体发生重叠时, 无非就是某个时刻两个球的位置有重叠部分。斥力模型:在下一帧绘制之前, 让球体脱离重叠状态。所以让下一帧绘制的时候, 如果球体处于重叠态, 直接置为非重叠状态。

在每次碰撞计算后, 进行下一帧的位置计算。如果下一帧中, 发生碰撞的两个球处于重叠状态, 那么就以两个球的圆心连线为角度, 以两球半径之和减去实际圆心的距离作为需要调整的量,

按照角度的cos值和sin值分别计算脱离重叠状态总共需要移动的x偏移量和y偏移量, 然后以两球的质量比作为分配原则, 将x偏移量和y偏移量分配给两个球, 让他们在下一帧绘制时脱离重叠状态。

分配原则是让球移动最少的距离来摆脱重叠状态, 所以分别比较两个球的球心x和y, 让小的一个的x值减少, 大的x值增加, y同理。在这种方案下, 我们能够基本保证, 发生重叠的帧不超过一帧, 在连续的动画下将难以察觉。

    update(ctrl: Controllers, timer: number, updateId: number, color) { 
    //球位置碰触到画布左右边界
    if (this.position.x + this.r >= ctrl.canvas.$canvas.width || this.position.x - this.r <= 0) {
      this.velocity = new Vector(-this.velocity.x, this.velocity.y)
    }
    //球的位置碰触到画布上下边界
    if (this.position.y + this.r >= ctrl.canvas.$canvas.height || this.position.y - this.r <= 0) {
      this.velocity = new Vector(this.velocity.x, -this.velocity.y);
    }

    //碰撞检测
    ctrl.elms.forEach((item: Balls) => {
      if (item === this) return;

      //俩圆心之间的距离
      const distance = Math.sqrt((this.position.x - item.position.x) ** 2 + (this.position.y - item.position.y) ** 2);

      if (distance <= this.r + item.r) {
        //如果已经被目标球记录过, 说明已经在发生碰撞时进行过计算了, 就直接跳过
        if (color === "color") {
          this.color = this.color === "#FF0000" ? "#00FF00" : "#FF0000";
          item.color = item.color === "#FF0000" ? "#00FF00" : "#FF0000";
        } else {
          if (item.collision.includes(this)) {
            return ;
          }
          const b1Velocity = this.collisionChanged(this, item);
          const b2Velocity = this.collisionChanged(item, this);
          this.velocity = b1Velocity;
          item.velocity = b2Velocity;
          /**
           * 为了避免球体粘连, 当发生碰撞后, 下一帧的更新必须让两个球体分开
           * 这里先计算下一帧时两个球体之间的距离, 如果已经处于分离状态
           * 则不需要进行干预, 如果仍旧有重叠部分, 改变两个碰撞球的位置
           * 改变的值为能够绘制时达到分离的值, 方向为两个圆心的夹角 
           */
          const nextPosA = this.position.add(this.velocity);
          const nextPosB = item.position.add(item.velocity);
          const nextDistance = Math.sqrt((nextPosA.x - nextPosB.x) ** 2 + (nextPosA.y - nextPosB.y) ** 2);
          if(nextDistance < this.r + item.r) {
            const deltaX = Math.abs(this.position.x - item.position.x);
            const deltaY = Math.abs(this.position.y - item.position.y);
            const degree = Math.atan2(deltaX, deltaY);
            const deltaPosX = (this.r + item.r) * Math.cos(degree);
            const deltaPosY = (this.r + item.r) * Math.sin(degree);
            if (this.position.x <= item.position.x) {
              this.position.x -= deltaX *(this.r / (this.r + item.r));
            } else {
              item.position.x -= deltaX *(item.r / (this.r + item.r));
            }
            if(this.position.y <= item.position.y) {
              this.position.y -= deltaY *(this.r / (this.r + item.r));
            } else {
              item.position.y -= deltaY *(item.r / (this.r + item.r));
            }
          }
          this.collision.push(this, item);
          item.collision.push(item, this);
        }
      }
    })
    //计算最终球的位置时, 进行边界判断, 避免因为碰撞发生卡入墙体
    const finalPosition = this.position.add(this.velocity);
    finalPosition.x = Math.min(ctrl.canvas.$canvas.width - this.r, Math.max(0 + this.r, finalPosition.x));
    finalPosition.y = Math.min(ctrl.canvas.$canvas.height - this.r, Math.max(0 + this.r, finalPosition.y));
    return Object.assign(this, {position: finalPosition})
  }