Search code examples
javascripthtml5-canvasgame-physicsparticle-system

Adding gravity to billiard physics in JS canvas animation


I am trying to write aa small physics demo using Javascript. I have multiple balls that bounce off each other just fine, but things go wrong when I try to add gravity.

I am trying to conserve the momentum once they hit, but when I add constant gravity to each one, the physics start to break down.

Here is what I have in terms of code:

class Ball {
  constructor ({
    x,
    y,
    vx,
    vy,
    radius,
    color = 'red',
  }) {
    this.x = x
    this.y = y
    this.vx = vx
    this.vy = vy
    this.radius = radius
    this.color = color

    this.mass = 1
  }
  render (ctx) {
    ctx.save()
    ctx.fillStyle = this.color
    ctx.strokeStyle = this.color
    ctx.translate(this.x, this.y)
    ctx.strokeRect(-this.radius, -this.radius, this.radius * 2, this.radius * 2)
    ctx.beginPath()
    ctx.arc(0, 0, this.radius, Math.PI * 2, false)
    ctx.closePath()
    ctx.fill()
    ctx.restore()
    return this
  }
  getBounds () {
    return {
      x: this.x - this.radius,
      y: this.y - this.radius,
      width: this.radius * 2,
      height: this.radius * 2
    }
  }
}

const intersects = (rectA, rectB) => {
  return !(rectA.x + rectA.width < rectB.x ||
           rectB.x + rectB.width < rectA.x ||
           rectA.y + rectA.height < rectB.y ||
           rectB.y + rectB.height < rectA.y)
}

const checkWall = (ball) => {
  const bounceFactor = 0.5
  if (ball.x + ball.radius > canvas.width) {
    ball.x = canvas.width - ball.radius
    ball.vx *= -bounceFactor
  }
  if (ball.x - ball.radius < 0) {
    ball.x = ball.radius
    ball.vx *= -bounceFactor
  }
  if (ball.y + ball.radius > canvas.height) {
    ball.y = canvas.height - ball.radius
    ball.vy *= -1
  }
  if (ball.y - ball.radius < 0) {
    ball.y = ball.radius
    ball.vy *= -bounceFactor
  }
}

const rotate = (x, y, sin, cos, reverse) => {
  return {
     x: reverse ? x * cos + y * sin : x * cos - y * sin,
     y: reverse ? y * cos - x * sin : y * cos + x * sin
   }
}

const checkCollision = (ball0, ball1, dt) => {
  const dx = ball1.x - ball0.x
  const dy = ball1.y - ball0.y
  const dist = Math.sqrt(dx * dx + dy * dy)
  const minDist = ball0.radius + ball1.radius
  if (dist < minDist) {
    //calculate angle, sine, and cosine
    const angle = Math.atan2(dy, dx)
    const sin = Math.sin(angle)
    const cos = Math.cos(angle)
    
    //rotate ball0's position
    const pos0 = {x: 0, y: 0}
    
    //rotate ball1's position
    const pos1 = rotate(dx, dy, sin, cos, true)
    
    //rotate ball0's velocity
    const vel0 = rotate(ball0.vx, ball0.vy, sin, cos, true)
    
    //rotate ball1's velocity
    const vel1 = rotate(ball1.vx, ball1.vy, sin, cos, true)
    
    //collision reaction
    const vxTotal = (vel0.x - vel1.x)
    vel0.x = ((ball0.mass - ball1.mass) * vel0.x + 2 * ball1.mass * vel1.x) /
      (ball0.mass + ball1.mass)
    vel1.x = vxTotal + vel0.x
    
    const absV = Math.abs(vel0.x) + Math.abs(vel1.x)
    const overlap = (ball0.radius + ball1.radius) - Math.abs(pos0.x - pos1.x)
    pos0.x += vel0.x / absV * overlap
    pos1.x += vel1.x / absV * overlap
    
    //rotate positions back
    const pos0F = rotate(pos0.x, pos0.y, sin, cos, false)
    const pos1F = rotate(pos1.x, pos1.y, sin, cos, false)
    
    //adjust positions to actual screen positions
    ball1.x = ball0.x + pos1F.x
    ball1.y = ball0.y + pos1F.y
    ball0.x = ball0.x + pos0F.x
    ball0.y = ball0.y + pos0F.y
    
    //rotate velocities back
    const vel0F = rotate(vel0.x, vel0.y, sin, cos, false)
    const vel1F = rotate(vel1.x, vel1.y, sin, cos, false)
    
    ball0.vx = vel0F.x
    ball0.vy = vel0F.y
    ball1.vx = vel1F.x
    ball1.vy = vel1F.y
  }
}


const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')

let oldTime = 0

canvas.width = innerWidth
canvas.height = innerHeight
document.body.appendChild(canvas)

const log = document.getElementById('log')

const balls = new Array(36).fill(null).map(_ => new Ball({
  x: Math.random() * innerWidth,
  y: Math.random() * innerHeight,
  vx: (Math.random() * 2 - 1) * 5,
  vy: (Math.random() * 2 - 1) * 5,
  radius: 20,
}))

requestAnimationFrame(updateFrame)

function updateFrame (ts) {
  const dt = ts - oldTime
  oldTime = ts

  ctx.clearRect(0, 0, innerWidth, innerHeight)
  
  for (let i = 0; i < balls.length; i++) {
    const ball = balls[i]
    // ADD GRAVITY HERE
    ball.vy += 2
    ball.x += ball.vx * (dt * 0.005)
    ball.y += ball.vy * (dt * 0.005)
    checkWall(ball)
  }
  
  for (let i = 0; i < balls.length; i++) {
    const ball0 = balls[i]
    for (let j = i + 1; j < balls.length; j++) {
      const ball1 = balls[j]
      // CHECK FOR COLLISIONS HERE
      checkCollision(ball0, ball1, dt)
    }
  }
  
  for (let i = 0; i < balls.length; i++) {
    const ball = balls[i]
    ball.render(ctx)
  }
  
  // const dist = ball2.x - ball1.x
//   if (Math.abs(dist) < ball1.radius + ball2.radius) {
//     const vxTotal = ball1.vx - ball2.vx
//     ball1.vx = ((ball1.mass - ball2.mass) * ball1.vx + 2 * ball2.mass * ball2.vx) / (ball1.mass + ball2.mass)
//     ball2.vx = vxTotal + ball1.vx

//     ball1.x += ball1.vx
//     ball2.x += ball2.vx
//   }
  

//     ball.vy += 0.5
//     ball.x += ball.vx
//     ball.y += ball.vy

//     

//     ball.render(ctx)
  
  requestAnimationFrame(updateFrame)
}
* { margin: 0; padding: 0; }

As you can see, I have checkCollision helper method, which calculates the kinetic energy and new velocities of a ball once it has collided with another ball. My update loop looks like this:

  // add velocities to balls position
  // check if its hitting any wall and bounce it back
  for (let i = 0; i < balls.length; i++) {
    const ball = balls[i]
    // Add constant gravity to the vertical velocity
    // When balls stack up on each other at the bottom, the gravity is still applied and my
    // "checkCollision" method freaks out and the physics start to explode
    ball.vy += 0.8
    ball.x += ball.vx * (dt * 0.005)
    ball.y += ball.vy * (dt * 0.005)
    checkWall(ball)
  }
  
  for (let i = 0; i < balls.length; i++) {
    const ball0 = balls[i]
    for (let j = i + 1; j < balls.length; j++) {
      const ball1 = balls[j]
      // Check collisions between two balls
      checkCollision(ball0, ball1, dt)
    }
  }
  
  // Finally render the ball on-screen
  for (let i = 0; i < balls.length; i++) {
    const ball = balls[i]
    ball.render(ctx)
  }

How do I calculate aa gravity, while preventing the physics from exploding when the balls start stacking on top of each other?

It seems that the gravity force is colliding with the "checkCollision" method. The checkCollision method tries to move them back in place, but the constant gravity overwrites it and continues pulling them down.

EDIT: After some reading I understand some Verlet integration is in order, but am having difficulties with wrapping my head around it.

for (let i = 0; i < balls.length; i++) {
        const ball = balls[i]
        // This line needs to be converted to verlet motion?
        ball.vy += 2
        ball.x += ball.vx * (dt * 0.005)
        ball.y += ball.vy * (dt * 0.005)
        checkWall(ball)
      }


Solution

  • Balls do not overlap

    There is a fundamental flaw in the collision testing due to the fact that the collisions are calculated only when 2 balls overlap. In the real world this never happens.

    The result of "collide on overlap" when many balls are interacting, will result in behavior that does not conserve the total energy of the system.

    Resolve by order of collision

    You can resolve collisions such that balls never overlap however the amount of processing is indeterminate per frame, growing exponentially as the density of balls increases.

    The approach is to locate the first collision between balls in the time between frames. Resolve that collision and then with the new position of that collision find the next collision closest in time forward from the last. Do that until there are no pending collisions for that frame. (There is more to it than that) The result is that the simulation will never be in the impossible state where balls overlap.

    Check out my Pool simulator on CodePen that uses this method to simulate pool balls. The balls can have any speed and always resolve correctly.

    Verlet integration.

    However you can reduce the noise using the overlapping collisions by using verlet integration which will keep the total energy of the balls at a more stable level.

    To do that we introduce 2 new properties of the ball, px, py that hold the previous position of the ball.

    Each frame we calculate the balls velocity as the difference between the current position and the new position. That velocity is used to do the calculations for the frame.

    When a ball changes direction (hits wall or another ball) we also need to change the balls previous position to match where it would have been on the new trajectory.

    Use constant time steps.

    Using time steps based on time since last frame will also introduce noise and should not be used in the overlap collision method.

    Reduce time, increase iteration

    To further combat the noise you need to slow the overall speed of the balls to reduce the amount they overlay and thus more closely behave as if they collided at the point ballA.radius + ballB.radius apart. Also you should test every ball against every other ball, not just ball against balls above it in the balls array.

    To keep the animation speed up you solve the ball V ball V wall collisions a few times per frame. The example does 5. The best value depends on the total energy of the balls, the level of noise that is acceptable, and the CPU power of the device its running on.

    Accuracy matters

    Your collision function is also way out there. I had a quick look and it did not look right. I added an alternative in the example.

    When a ball hits a wall it does so at some time between the frames. You must move the ball away from the wall by the correct distance. Not doing so is like simulating a ball that sticks to a wall a tiny bit each time it hits, further diverging from what really happens.

    Example

    This is a rewrite of your original code. Click canvas to add some energy.

    const ctx = canvas.getContext("2d");
    const BOUNCE = 0.75;
    const resolveSteps = 5;
    var oldTime = 0;
    const $setOf = (count, fn = (i) => i) => {var a = [], i = 0; while (i < count) { a.push(fn(i++)) } return a };
    const $rand  = (min = 1, max = min + (min = 0)) => Math.random() * (max - min) + min;
    const $randP  = (min = 1, max = min + (min = 0), p = 2) => Math.random() ** p * (max - min) + min;
    var W = canvas.width, H = canvas.height;
    const BALL_COUNT = 80;
    const BALL_RADIUS = 15, BALL_MIN_RADIUS = 6;
    const GRAV = 0.5 / resolveSteps;
    requestAnimationFrame(updateFrame);
    
    canvas.addEventListener("click", () => {
      balls.forEach(b => {
        b.px = b.x + (Math.random() * 18 - 9);
        b.py = b.y + (Math.random() * -18);
      })
    });
    class Ball {
      constructor({x, y, vx, vy, radius}) {
        this.x = x;
        this.y = y;
        this.px = x - vx;
        this.py = y - vy;
        this.vx = vx;
        this.vy = vy;
        this.radius = radius;
        this.mass = radius * radius * Math.PI * (4 / 3); // use sphere volume as mass
      }
      render(ctx) {
        ctx.moveTo(this.x + this.radius, this.y);
        ctx.arc(this.x, this.y, this.radius, Math.PI * 2, false);
      }
      move() {
        this.vx = this.x - this.px;
        this.vy = this.y - this.py;
        this.vy += GRAV;
        this.px = this.x;
        this.py = this.y;
        this.x += this.vx;
        this.y += this.vy;
        this.checkWall();
      }
      checkWall() {
        const ball = this;
        const top = ball.radius;
        const left = ball.radius;
        const bottom = H - ball.radius;
        const right = W - ball.radius;
        if (ball.x > right) {
          const away = (ball.x - right) * BOUNCE;
          ball.x = right - away;
          ball.vx = -Math.abs(ball.vx) * BOUNCE;
          ball.px = ball.x - ball.vx;
        } else if (ball.x < left) {
          const away = (ball.x - left) * BOUNCE;
          ball.x = left + away;
          ball.vx = Math.abs(ball.vx) * BOUNCE;
          ball.px = ball.x - ball.vx;
        }
        if (ball.y > bottom) {
          const away = (ball.y - bottom) * BOUNCE;
          ball.y = bottom - away;
          ball.vy = -Math.abs(ball.vy) * BOUNCE;
          ball.py = ball.y - ball.vy;
        } else if (ball.y < top) {
          const away = (ball.y - top) * BOUNCE;
          ball.y = top + away;
          ball.vy = Math.abs(ball.vy) * BOUNCE;
          ball.py = ball.y - ball.vy;
        }
      }
      collisions() {
        var b, dx, dy, nx, ny, cpx, cpy, p, d, i = 0;
        var {x, y, vx, vy, px, py, radius: r, mass: m} = this;
        while (i < balls.length) {
          b = balls[i++];
          if (this !== b) {
            const rr = r + b.radius;
            if (x + rr > b.x && x < b.x + rr && y + rr > b.y && y < b.y + rr) {
              dx = x - b.x;
              dy = y - b.y;
              d = (dx * dx + dy * dy) ** 0.5;
              if (d < rr) {
                nx = (b.x - x) / d;
                ny = (b.y - y) / d;
                p = 2 * (vx * nx + vy * ny - b.vx * nx - b.vy * ny) / (m + b.mass);
                cpx = (x * b.radius + b.x * r) / rr;
                cpy = (y * b.radius + b.y * r) / rr;
                x = cpx + r * (x - b.x) / d;
                y = cpy + r * (y - b.y) / d;
                b.x = cpx + b.radius * (b.x - x) / d;
                b.y = cpy + b.radius * (b.y - y) / d;
                px = x - (vx -= p * b.mass * nx);
                py = y - (vy -= p * b.mass * ny);
                b.px = b.x - (b.vx += p * m * nx);
                b.py = b.y - (b.vy += p * m * ny);
              }
            }
          }
        }
        this.x = x;
        this.y = y;
        this.px = px;
        this.py = py;
        this.vx = vx;
        this.vy = vy;
        this.checkWall();
      }
    }
    const balls = (() => {
      return $setOf(BALL_COUNT, () => new Ball({
        x: $rand(BALL_RADIUS, W - BALL_RADIUS),
        y: $rand(BALL_RADIUS, H - BALL_RADIUS),
        vx: $rand(-2, 2),
        vy: $rand(-2, 2),
        radius: $randP(BALL_MIN_RADIUS, BALL_RADIUS, 4),
      }));
    })();
    
    function updateFrame(ts) {
      var i = 0, j = resolveSteps;
      ctx.clearRect(0, 0, W, H);
    
      while (i < balls.length) { balls[i++].move() }
      while (j--) {
        i = 0;
        while (i < balls.length) { balls[i++].collisions(balls) }
      }
      ctx.fillStyle = "#0F0";
      ctx.beginPath();
      i = 0;
      while (i < balls.length) { balls[i++].render(ctx) }
      ctx.fill();
      requestAnimationFrame(updateFrame)
    }
    <canvas id="canvas" width="400" height="180" style="border:1px solid black;"></canvas>
    <div style="position: absolute; top: 10px; left: 10px;">Click to stir</div>