HTML Canvasで物理シミュレーション

HTML上で絵を描くことができるCanvasを使って、簡単な物理シミュレーションをやってみたいと思います。今回扱うのは斜方投射、つまり物を斜め上に投げたときの運動です。
※ここではCanvasの基本的なことは理解している前提で話を進めていきます。

Canvasの準備

まずはCanvasの準備です。サイズはなんでもかまいません。

<canvas id="canvas" width="640" height="480"></canvas>
const canvas = document.getElementById('canvas'),
  ctx = canvas.getContext('2d');

Canvasはラスター画像として書き出されるので、高解像度ディスプレイで見るとぼやけてしまいます。普通の画像と同じように、大きく描いてCSSで縮小させてやることで解決できます。

const dpr = window.devicePixelRatio || 1,
  width = canvas.width,
  height = canvas.height;

// Canvasをピクセル比で拡大
canvas.width *= dpr;
canvas.height *= dpr;
// CSSで元のサイズに戻す
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
// Canvasの描画自体を拡大
ctx.scale(dpr, dpr);

これでスマホなどで見てもきれいな画像が表示されます。

座標系の設定

Canvasの座標は左上原点で下向き正です。これでは扱いにくいので、数学や物理で一般的な上向き正の座標に変換します。また、今回は斜方投射なので、原点は左下に取ります。

// y座標を反転
ctx.scale(1, -1);
// y軸に沿って高さ分下にずらす
ctx.translate(0, -height);

高解像度対応と合わせて一行で書くとこうです。

ctx.transform(dpr, 0, 0, -dpr, 0, height * dpr);

これで座標系の設定は完了です。

Canvasアニメーション

Canvasにはアニメーションを直接指定するようなメソッドはありませんが、パラパラ漫画の要領で消して描いてを繰り返すことで、簡単にアニメーションさせることができます。

以下は円を右に動かすアニメーションの例です。

let x = 80, y = 240; // 円の位置

const tick = () => {
  // 画面の消去
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 円の描画
  ctx.beginPath();
  ctx.arc(x, y, 25, 0, Math.PI * 2);
  ctx.fill();

  // 位置を変更
  x += 1;

  requestAnimationFrame(tick);
};
requestAnimationFrame(tick);

requestAnimationFrame はブラウザの描画タイミングに合わせて実行される(さらに間隔も一定とは限らない)ため、環境によって移動スピードがまちまちになってしまいます。

一定のスピードで動かすためには、前回の呼び出しからの経過時間を使用する方法があります。

let x = 80, y = 240, // 円の位置
  vx = 60, // X方向の速さ [px/s]
  lastTime; // 前回の呼び出し時間 [ms]

const tick = (time) => {
  if (!lastTime) lastTime = time;
  const dt = (time - lastTime) / 1000; // 経過時間 [s]

  // 位置を変更
  x += vx * dt;

  // 画面の消去
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 点の描画
  ctx.beginPath();
  ctx.arc(x, y, 25, 0, Math.PI * 2);
  ctx.fill();

  lastTime = time;
  requestAnimationFrame(tick);
};
requestAnimationFrame(tick);

See
the Pen
Canvas Animation
by SHIN Inc. (@shin-inc)
on CodePen.0

これで一定のスピードで動くアニメーションができました(ただし物理シミュレーションとしてはこれでは不満です。詳しくは後述)。

Y軸方向の動きを追加すれば、斜めの動きも可能です。

自由落下

準備が整ったので、実際にシミュレーターを作っていきます。まずは自由落下をシミュレートしてみましょう。

落とす物体は大きさのない質点とし、重力加速度を9.8m/s²として計算します。加速度がある場合は、位置の更新に加えて速度の更新が必要です。

また、1pxを1mとすると大きすぎるので、10pxを1mで計算しています。

let x = 32, y = 40, // 点の位置 [m]
  vy = 0, // Y方向の速さ [m/s]
  lastTime; // 前回の呼び出し時間 [ms]

const g = -9.8, // 重力加速度
    scale = .1; // 1px当たりのメートル

const tick = (time) => {
  if (!lastTime) lastTime = time;
  const dt = (time - lastTime) / 1000; // 経過時間 [s]

  // 位置と速度を変更
  y += vy * dt;
  vy += g * dt;

  // 画面の消去
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 点の描画
  ctx.beginPath();
  ctx.arc(x / scale, y / scale, 1, 0, Math.PI * 2);
  ctx.fill();

  lastTime = time;
  requestAnimationFrame(tick);
};
requestAnimationFrame(tick);

See
the Pen
Free Fall
by SHIN Inc. (@shin-inc)
on CodePen.0

加速しながら落ちていく様子がわかると思います。ただこのままだと、地面(y=0)を突き抜けてしまうので、停止条件を追加します。

let x = 32, y = 40, // 点の位置 [m]
  vy = 0, // Y方向の速さ [m/s]
  lastTime; // 前回の呼び出し時間 [ms]
  
const g = -9.8, // 重力加速度
    scale = .1; // 1px当たりのメートル

const tick = (time) => {
  if (!lastTime) lastTime = time;
  const dt = (time - lastTime) / 1000; // 経過時間 [s]

  // 位置と速度を変更
  y += vy * dt
  y = Math.max(y, 0); // yは常に0以上
  vy += g * dt;

  // 画面の消去
  ctx.clearRect(0, 0, canvas.width, canvas.height);

  // 点の描画
  ctx.beginPath();
  ctx.arc(x / scale, y / scale, 1, 0, Math.PI * 2);
  ctx.fill();

  // yが0より大きいときのみ次の処理を行う
  if (y > 0) {
    lastTime = time;
    requestAnimationFrame(tick);
  }
};
requestAnimationFrame(tick);

これで地面まで落ちたら自動的に停止します。

See the Pen
Free Fall (stop)
by SHIN Inc. (@shin-inc)
on CodePen.0


斜方投射

それでは当初の目的である斜方投射のシミュレーションを行ってみましょう。また、後々改良しやすくするために、ちょっとだけ手を入れます。

まずは更新と描画を分けます。

let stop = false; // 停止判定

const update = (dt) => {
  x += vx * dt;
  y += vy * dt;
  y = Math.max(y, 0);
  /* vx += 0; 今回は横方向の加速度はない */
  vy += g * dt;

  if (dt && y === 0) {
    stop = true; // y = 0 なら停止(ただし初回は無視)
  }
};

const draw = () => {
  // 画面の消去
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  // 点の描画
  ctx.beginPath();
  ctx.arc(x / scale, y / scale, 1, 0, Math.PI * 2);
  ctx.fill();
};

const tick = (time) => {
  if (!lastTime) lastTime = time;
  const dt = (time - lastTime) / 1000; // 経過時間 [s]

  update(dt);
  draw();

  if (!stop) {
    lastTime = time;
    requestAnimationFrame(tick);
  }
};

こうしておくと、draw()update() を差し替えることで別のアニメーションを行うことができるようになります。

斜方投射なので初期位置は左下です。停止条件をy=0とすると、最初から動かなくなってしまうので、初回描画時(dt=0)のみ判定を無視するようにしています。

これを今までの tick() と差し替えてやります。また、今回はx軸方向の速さもあります。

let x = 0, y = 0, // 点の位置 [m]
  vx = 10, // X方向の速さ [m/s]
  vy = 25, // Y方向の速さ [m/s]
  lastTime, // 前回の呼び出し時間 [ms]
  stop = false; // 停止判定
  
const g = -9.8, // 重力加速度
    scale = .1; // 1px当たりのメートル

const update = (dt) => {
  x += vx * dt;
  y += vy * dt;
  y = Math.max(y, 0);
  /* vx += 0; 今回は横方向の加速度はない */
  vy += g * dt;
  
  if (dt && y === 0) {
    stop = true; // y = 0 なら停止(ただし初回は無視)
  }
};

const draw = () => {
  // 画面の消去
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  
  // 点の描画
  ctx.beginPath();
  ctx.arc(x / scale, y / scale, 1, 0, Math.PI * 2);
  ctx.fill();
};

const tick = (time) => {
  if (!lastTime) lastTime = time;
  const dt = (time - lastTime) / 1000; // 経過時間 [s]
  
  update(dt);
  draw();
  
  if (!stop) {
    lastTime = time;
    requestAnimationFrame(tick);
  }
};
requestAnimationFrame(tick);

See
the Pen
Projectile Motion
by SHIN Inc. (@shin-inc)
on CodePen.0

どうでしょうか。きれいな放物線を描いて点が移動するのがわかると思います。

初速度をxとyでそれぞれ指定するのはわかりにくいので、初速と角度で指定できるようにすると、さらに扱いやすくなります。

const v = 25, // 初速 [m/s]
  deg = 60; // 角度 [°]

let vx = v * Math.cos(deg * Math.PI / 180),
  vy = v * Math.sin(deg * Math.PI / 180);

以下に初速と角度を変更できるシミュレーターを用意しました。角度によって到達距離や高度が変わることを確かめてみてください。

See the Pen
Projectile Motion Simulator
by SHIN Inc. (@shin-inc)
on CodePen.0


課題

無事斜方投射シミュレーターが完成しました。ですが、先述したとおり requestAnimationFrame の仕様によりあまりよいシミュレーターとは言えません。斜方投射や自由落下のシミュレーション程度なら誤差の範囲でしょうが、物理シミュレーションがやることは数値計算による積分なので、各ステップの間隔(刻み幅)が変化しうるというのは精度的に問題があるのです。同じ条件でシミュレートしても、人によって違う結果が出る可能性があります。

これを解決するためには、FPSを固定して、フレーム数による計算を行う必要が出てきます。つまり、コールバックが呼び出された時点で、前回から何フレーム経過したかを計算し、そのフレーム数分、更新計算を行わなければいけません。

それから、今回は質点1個のシミュレーションですが、複数同時にシミュレーションするならば、質点の管理をもっとうまくやる必要が出てきます。

これらの課題解決は、また次回ということにしたいと思います。

続き → HTML Canvasで物理シミュレーション その2