HTML5のCanvasでつくるダイナミックな表現―CreateJSを使う

第31回位置座標の相互作用で弾力を表す

今回からは、つぎのお題に取り組む。多角形の頂点を結んだかたちが、放物線状に落ちてきて、弾力のある動き方をする。それぞれの頂点の間の力のかかり方や、それらが弾むときの方向、他の頂点への力の変化など、かなり複雑な計算が求められるように感じるだろう。実は四則演算を使った簡単な式で、このアニメーションはつくられている。

クラスでつくった点を放物線状に落とす

第24回「マウスポインタの動きに弾みがついた曲線を滑らかに描く」では、⁠オイラー法」という考え方にもとづき、バネのような動きを加速度と速度の四則演算で表した(⁠バネのような動きを数学の目で確かめる参照⁠⁠。速度を位置に足し込めば、新たな位置が求められる。さらに、速度も変わるなら、加速度を加えて新たな速度が導けた。

今回のアニメーションも、やはり近似計算で式を簡単にする。オイラー法と異なるのは、時系列の位置座標を使って運動が表されることだ。ベレ法と呼ばれ、動きが座標間の関係を示す四則演算で導けてしまう。

まず、アニメーションするかたちは点からつくられる。その点をクラス(VerletPoint)として定めよう。クラスのつくり方については、第17回簡単なクラスを定義するをご参照いただきたい。いつもの解説とは少し進め方を変えて、いきなりクラスのプロパティとメソッドの役割および働きを決めることにする。

プロパティ
  • x, y:オブジェクトの現在のxy座標。
  • _oldX, _oldY:オブジェクトの前のxy座標。
メソッド
  • VerletPoint(x, y):コンストラクタ。xy座標値を引数に渡す。
  • update():オブジェクトの座標のプロパティ値を、速度にもとづいて更新する。
  • constrain(rect):引数にRectangleオブジェクトを受取って、オブジェクトの座標をその矩形領域内に収める。
  • getVelocity():オブジェクトの新旧xy座標から速度を求めて返す。
  • render(graphics):引数に受取ったGraphicsオブジェクトに、オブジェクトの現行xy座標の点を描く。
  • addCoordinates(x, y):引数に受取ったxyピクセル値を、オブジェクトのxy座標に加える。

点のクラス(VerletPoint)は以下のコード1のように定めた。このクラスをどう使ってアニメーションにつなげるのかは別にして、上の説明と照らし合わせれば、プロパティとメソッドひとつひとつが何をしているのかはほぼおわかりいただけるだろう。それらを簡単に説明していこう。

コンストラクタVerletPoint()の引数にはインスタンスのxy座標値を渡して、その値が新旧xy座標のプロパティに初期値として与えられる。インスタンスを動かせば、今の座標とその前の座標が、それぞれのプロパティに定められることになる。

update()メソッドは、インスタンスを動かしてプロパティの値を改める。動かすための速度はgetVelocity()メソッドが、古い座標と新しい座標の差から導いてPointオブジェクトで返す。そのため、このクラスは速度をプロパティにもたない。速度(ベクトル)のxy座標値をaddCoordinates()メソッドでオブジェクトの座標に加えて、新旧座標のプロパティが更新される。

constrain()メソッドは、オブジェクトの座標を引数のRectangleオブジェクトが示す矩形領域の中に収める。Canvasの矩形領域を引数のRectangleオブジェクトとして与えれば、アニメーションする点はその領域の外に出ることはない。

render()メソッドは、黒点を引数のGraphicsオブジェクトに描く。描画を消すGraphics.clear()メソッドやステージ再描画のStage.update()メソッドは呼んでいない。後々多くの点や線を描くため、これらのメソッドはクラスの外でまとめて呼び出したほうがよいからだ。

コード1 点を定めるクラスVerletPoint
function VerletPoint(x, y) {
  this.x = this._oldX = x;
  this.y = this._oldY = y;
}
VerletPoint.prototype.update = function() {
  var tempX = this.x;
  var tempY = this.y;
  var velocity = this.getVelocity();
  this.addCoordinates(velocity.x, velocity.y);
  this._oldX = tempX;
  this._oldY = tempY;
};
VerletPoint.prototype.constrain = function(rect) {
  var left = rect.x;
  var right = left + rect.width;
  var top = rect.y;
  var bottom = top + rect.height;
  if (this.x < left) {
    this.x = left;
  } else if (this.x > right) {
    this.x = right;
  }
  if (this.y < top) {
    this.y = top;
  } else if (this.y > bottom) {
    this.y = bottom;
  }
};
VerletPoint.prototype.getVelocity = function() {
  var velocity = new createjs.Point(this.x - this._oldX, this.y - this._oldY);
  return velocity;
};
VerletPoint.prototype.render = function(graphics) {
  graphics.beginFill("black")
  .drawCircle(this.x, this.y, 2.5)
  .endFill();
};
VerletPoint.prototype.addCoordinates = function(x, y) {
  this.x += x;
  this.y += y;
};

このクラス(VerletPoint)でつくったひとつの黒点を放物線状に落としてみよう図1⁠。script要素に書いたJavaScriptは以下のコード1のとおりだ。いつものお約束どおり、初期化の関数(initialize())はbody要素のonload属性で呼び出す。

<body onLoad="initialize()">
  <canvas id="myCanvas" width="400" height="300"></canvas>
</body>
図1 黒点が放物線状に落ちる
図1 黒点が放物線状に落ちる 図1 黒点が放物線状に落ちる 図1 黒点が放物線状に落ちる

初期化の関数(initialize())は、ステージに描画用のShapeオブジェクト(shape)を加え、そのGraphicsオブジェクトを変数(drawingGraphics)にとった。そして、Canvasの矩形領域をRectangleオブジェクトに与えて変数に納める(_stageRect⁠⁠。そのうえで、コード1のクラス(VerletPoint)からつくったオブジェクトを変数(_point)に入れ、x座標値を動かした。アニメーションは、Ticker.tickイベントに加えたリスナー関数(draw())で扱う。

アニメーションのリスナー関数(draw())がまず呼び出すのは、この後説明する点を動かす関数(updatePoint())だ。そして、描画のGraphicsオブジェクトをクリアしてから、点のオブジェクト(_point)のrender()メソッドで新たな点を描く。

点を動かす関数(updatePoint())は、点のオブジェクト(_point)のy座標を動かしてオブジェクトのupdate()メソッドで更新した後、動く範囲をconstrain()メソッドでCanvasの矩形領域(_stageRect)に納めた。

コード2 点を放物線状に落とす
var stage;
var drawingGraphics;
var _point;
var _stageRect;
var velocityX = 5;
var velocityY = 0.25;
function initialize() {
  var canvasElement = document.getElementById("myCanvas");
  var shape = new createjs.Shape();
  stage = new createjs.Stage(canvasElement);
  stage.addChild(shape);
  drawingGraphics = shape.graphics;
  _stageRect = new createjs.Rectangle(
    0,
    0,
    canvasElement.width,
    canvasElement.height
  );
  _point = new VerletPoint(50, 50);
  _point.x += velocityX;
  createjs.Ticker.timingMode = createjs.Ticker.RAF;
  createjs.Ticker.addEventListener("tick", draw);
}
function draw(eventObject) {
  updatePoint();
  drawingGraphics.clear();
  _point.render(drawingGraphics);
  stage.update();
}
function updatePoint() {
  _point.y += velocityY;
  _point.update();
  _point.constrain(_stageRect);
}

実際の動きを確かめた方がよいので、jsdo.itに前掲コード1およびコード2を掲げた。コード1のクラス定義は[HTML]の欄に分けて書いている。見ると、何の変哲もないアニメーションだ。黒い点が、放物線状に落ちる。とくに弾ませる処理は加えていないため、Canvas下端に落ちると、そのまま滑って右端で止まる。

しかし、前掲コード2と動きをよく見比べると、ある特徴に気づく。点のオブジェクト(_point)のx座標を動かしたのは、初期設定の関数(initialize())の中で1度きりだ。それでも水平方向に動き続けるのは、点のクラス(VerletPoint())が速度をgetVelocity()メソッドにより、前の座標と今の座標の差で捉えているためだ。つまり、点のクラスのオブジェクトは、ひとたび動かせば、そのまま放っておいても同じ向きに動き続ける仕組みになっている。

アニメーションのリスナー関数(draw())からは、点を動かす関数(updatePoint())が呼び出された。そして、その関数が呼び出されるたびに、点のオブジェクト(_point)のy座標に一定の値を加えている。その結果、垂直方向には重力がかかったような動きになった。すなわち、点のオブジェクトの座標を動かすことは、今の動きにさらに力を加えるのに等しいといえる[1]⁠。

ふたつの点を棒でつなぐ

つぎに、落とす点をふたつにして棒でつなぐ。この棒もクラス(VerletStick)で定める。また、クラスのプロパティとメソッドを先に紹介しよう。プロパティは、棒でつなぐふたつの点のVerletPointオブジェクト(_point0と_point1)のほか、棒の長さ(_length)とその長さの伸び縮みをどれくらい許すかという固さ(elasticity)だ。

コンストラクタVerletStick()には、これら4つのプロパティ値を引数に渡す。update()メソッドは、ふたつの点のオブジェクトの位置を棒の長さに合わせて調整して更新する。そして、render()メソッドが、引数のGraphicsオブジェクトに、棒を直線で描く。

プロパティ
  • _point0, _point1:棒でつなぐふたつの点のVerletPointオブジェクト。
  • _length:棒の長さ。
  • elasticity:棒の伸縮を許す固さ。
メソッド
  • VerletStick(point0, point1, length, elasticity):コンストラクタ。前述4つのプロパティ値を引数に渡す。
  • update():ふたつの点の座標を棒がつなげるように調整して更新する。
  • render(graphics):引数に受取ったGraphicsオブジェクトに、棒を直線で描く。

これからつくるのは、ふたつの点を棒でつないで落とすアニメーションだ。それがどういう動きになるのか、先に見てしまった方がわかりやすいだろう。点がひとつのときと同じように、棒につながれたふたつの点が放物線を描いて落ち、Canvas下端を滑っていく。違うのは、右端で跳ね返ることだ。

棒のクラス(VerletStick)を定める前に、点のクラス(VerletPoint)にメソッドを加える。棒のオブジェクトが長さを計算するときに、ふたつの点の間の距離を求めなければならない。そのためのメソッドとして、以下の3つを新たに備えた。まず、subtract()メソッドは、引数に受取った点との差をVerletPointオブジェクト(ベクトル)で返す。つぎに、getLength()メソッドが、その点の原点からの長さを求める。すると、3つ目のgetDistance()メソッドは、前のふたつのメソッドにより、引数の点との距離を導いて返す。

VerletPoint.prototype.subtract = function(_point) {
  var subtractedPoint = new VerletPoint(this.x - _point.x, this.y - _point.y);
  return subtractedPoint;
};
VerletPoint.prototype.getLength = function() {
  var dx = this.x;
  var dy = this.y;
  var length = Math.sqrt(dx * dx + dy * dy);
  return length;
};
VerletPoint.prototype.getDistance = function(_point) {
  var distancePoint = this.subtract(_point);
  return distancePoint.getLength();
};

そして、棒のクラス(VerletStick)を定める。コンストラクタとrender()メソッドは、つぎのような簡単なJavaScriptコードだ。コンストラクタは、4つのプロパティに値を与えている。棒の長さ(_lengthプロパティ)は、先ほど点のクラス(VerletPoint)に加えたメソッドgetDistance()で求めた。render()メソッドは、引数のGraphicsオブジェクトに直線を引くだけだ。

function VerletStick(point0, point1) {
  this.elasticity = 0.5;
  this._point0 = point0;
  this._point1 = point1;
  this._length = point0.getDistance(point1);
}

VerletStick.prototype.render = function(graphics) {
  graphics.beginStroke("black")
  .setStrokeStyle(0.5)
  .moveTo(this._point0.x, this._point0.y)
  .lineTo(this._point1.x, this._point1.y);
};

update()メソッドは、ふたつの点が棒から外れないようにそれらの位置を調整する。以下の図2と併せて、計算を見てゆこう。ふたつの点(_point0と_point1プロパティ)の差のベクトル(delta)の長さ(distance)が、2点間の実際の距離になる。その値と棒に定められた長さ(_lengthプロパティ)との誤差(difference)を埋めなければならない。

そこで、三角比を用いた。2点の差のベクトル(delta)のx成分(delta.x)とy(delta.y)成分を、それぞれ2点間の距離(distance)で割ると、cos(= delta.x / distance)とsin(= delta.y / distance)が求まる。それらを誤差(difference)に乗じれば、誤差のxy成分が得られる(三角関数については、第11回「マウスポインタの動きに合わせてインスタンスをランダムに落とす」インスタンスの動きに水平方向の初速を加える参照⁠⁠。その値を半分(elasticityプロパティ)にしてふたつの点の座標に正負逆で加えれば、互いに同じだけ近づいたり遠ざかったりすることによって棒の長さが保たれる。

VerletStick.prototype.update = function() {
  var delta = this._point1.subtract(this._point0);
  var distance = delta.getLength();
  var difference = this._length - distance;
  var offsetX = (difference * delta.x / distance)  * this.elasticity;
  var offsetY = (difference * delta.y / distance)  * this.elasticity;
  this._point0.addCoordinates(-offsetX, -offsetY);
  this._point1.addCoordinates(offsetX, offsetY);
};
図2 2点間の距離と棒に定められた長さの誤差を埋める
図2 2点間の距離と棒に定められた長さの誤差を埋める

点(VerletPoint)と棒(VerletStick)のクラスは、それぞれ以下のコード3コード4のように定められた。これらのクラスからつくる棒でつながれたふたつの点を、この後放物線状に落とす。

コード3 点を定めるクラスVerletPointにメソッドを追加
function VerletPoint(x, y) {
  this.x = this._oldX = x;
  this.y = this._oldY = y;
}
VerletPoint.prototype.update = function() {
  var tempX = this.x;
  var tempY = this.y;
  var velocity = this.getVelocity();
  this.addCoordinates(velocity.x, velocity.y);
  this._oldX = tempX;
  this._oldY = tempY;
};
VerletPoint.prototype.constrain = function(rect) {
  var left = rect.x;
  var right = left + rect.width;
  var top = rect.y;
  var bottom = top + rect.height;
  if (this.x < left) {
    this.x = left;
  } else if (this.x > right) {
    this.x = right;
  }
  if (this.y < top) {
    this.y = top;
  } else if (this.y > bottom) {
    this.y = bottom;
  }
};
VerletPoint.prototype.getVelocity = function() {
  var velocity = new createjs.Point(this.x - this._oldX, this.y - this._oldY);
  return velocity;
};
VerletPoint.prototype.render = function(graphics) {
  graphics.beginFill("black")
  .drawCircle(this.x, this.y, 2.5)
  .endFill();
};
VerletPoint.prototype.addCoordinates = function(x, y) {
  this.x += x;
  this.y += y;
};
VerletPoint.prototype.subtract = function(_point) {
  var subtractedPoint = new VerletPoint(this.x - _point.x, this.y - _point.y);
  return subtractedPoint;
};
VerletPoint.prototype.getLength = function() {
  var dx = this.x;
  var dy = this.y;
  var length = Math.sqrt(dx * dx + dy * dy);
  return length;
};
VerletPoint.prototype.getDistance = function(_point) {
  var distancePoint = this.subtract(_point);
  return distancePoint.getLength();
};
コード4 棒を定めるクラスVerletStick
function VerletStick(point0, point1) {
  this.elasticity = 0.5;
  this._point0 = point0;
  this._point1 = point1;
  this._length = point0.getDistance(point1);
}
VerletStick.prototype.update = function() {
  var delta = this._point1.subtract(this._point0);
  var distance = delta.getLength();
  var difference = this._length - distance;
  var offsetX = (difference * delta.x / distance)  * this.elasticity;
  var offsetY = (difference * delta.y / distance)  * this.elasticity;
  this._point0.addCoordinates(-offsetX, -offsetY);
  this._point1.addCoordinates(offsetX, offsetY);
};
VerletStick.prototype.render = function(graphics) {
  graphics.beginStroke("black")
  .setStrokeStyle(0.5)
  .moveTo(this._point0.x, this._point0.y)
  .lineTo(this._point1.x, this._point1.y);
};

棒でつないだふたつの点を落とす

いよいよ、(VerletPoint)と棒(VerletStick)のふたつのクラスからインスタンスをつくり、棒でつながれたふたつの点を放物線状に落とす。前掲コード2に以下のような手を加える。まず、変数だ。点の数は次回以降さらに増えるので配列(_points)で定めた。それから、棒のオブジェクトを入れる変数(_stick)も加える(こちらも、次回配列に差し替える⁠⁠。

つぎに、初期化の関数(initialize())で、点のオブジェクトふたつを配列(_points)に加えた。棒のオブジェクトもやはり変数(_stick)に納める。注目していただきたいのは、(velocityX)を加えるのがひとつの点だけということだ。もうひとつの点は、棒につながれて動くことになる。

Ticker.tickイベントのリスナー関数(draw())は、新たな関数(updatePoints())でふたつの点を動かし、棒のメソッド(update())でつなぎ、ふたつの点を別の関数(renderPoints())で描いて、棒の描画メソッド(render())を呼出す。ふたつの点を動かす関数と、描画する関数は、配列から点のオブジェクトをforループで取り出して、前掲コード2と同じ内容の処理を行っている。

// var _point;
var _points = [];
var _stick;
function initialize() {

  // _point = new VerletPoint(50, 50);
  _points.push(new VerletPoint(70, 100));
  _points.push(new VerletPoint(50, 25));
  // _point.x += velocityX;
  _points[0].x += velocityX;
  _stick = new VerletStick(_points[0], _points[1]);

}
function draw(eventObject) {
  // updatePoint();
  updatePoints();
  _stick.update();

  // _point.render(drawingGraphics);
  renderPoints();
  _stick.render(drawingGraphics);

}
/*
function updatePoint() {
  _point.y += velocityY;
  _point.update();
  _point.constrain(_stageRect);
}
*/
function updatePoints() {
  var count = _points.length;
  for (var i = 0; i < count; i++) {
    var point = _points[i];
    point.y += velocityY;
    point.update();
    point.constrain(_stageRect);
  }
}
function renderPoints() {
  var count = _points.length;
  for (var i = 0; i < count; i++) {
    var point = _points[i];
    point.render(drawingGraphics);
  }
}

ふたつのクラス(VerletPointとVerletStick)からつくった2点と棒のインスタンスをつないで、放物線状に落とすJavaScriptは以下のコード5のとおりだ。力は前掲コード2と同じく、水平方向には初めに1度だけひとつの点に与え、垂直方向にはふたつの点それぞれに重力を加えている。すると、下端に落ちても弾まない点が、右端に当たると跳ね返されるように逆向きに動いた。これは棒の働きだ。

棒のクラス(VerletStick)のupdate()メソッドは、長さが保たれるように両端の2点の位置を調整した。右端にぶつかった点は、それ以上進めない。しかし、後ろの点は右に動こうとする。それでは棒の長さが詰まってしまうので、update()メソッドが両端の点を外側に押し返す。後ろの点は戻されるものの、前の点はやはり動けない。それが繰返されるうちに、後ろの点は逆向きに進み出し、前の点はそれに引っ張られる。こうして、跳ね返る動きになったのだ。

コード5 ふたつの点を棒でつないで放物線状に落とす
var stage;
var drawingGraphics;
var _points = [];
var _stick;
var _stageRect;
var velocityX = 5;
var velocityY = 0.25;
function initialize() {
  var canvasElement = document.getElementById("myCanvas");
  var shape = new createjs.Shape();
  stage = new createjs.Stage(canvasElement);
  stage.addChild(shape);
  drawingGraphics = shape.graphics;
  _stageRect = new createjs.Rectangle(
    0,
    0,
    canvasElement.width,
    canvasElement.height
  );
  _points.push(new VerletPoint(70, 100));
  _points.push(new VerletPoint(50, 25));
  _points[0].x += velocityX;
  _stick = new VerletStick(_points[0], _points[1]);
  createjs.Ticker.timingMode = createjs.Ticker.RAF;
  createjs.Ticker.addEventListener("tick", draw);
}
function draw(eventObject) {
  updatePoints();
  _stick.update();
  drawingGraphics.clear();
  renderPoints();
  _stick.render(drawingGraphics);
  stage.update();
}
function updatePoints() {
  var count = _points.length;
  for (var i = 0; i < count; i++) {
    var point = _points[i];
    point.y += velocityY;
    point.update();
    point.constrain(_stageRect);
  }
}
function renderPoints() {
  var count = _points.length;
  for (var i = 0; i < count; i++) {
    var point = _points[i];
    point.render(drawingGraphics);
  }
}

引数のデフォルト値と値の確認

先にお見せしたjsdo.itのVerletStickクラスは、コンストラクタの引数が4つあり、ステートメント数も前掲コード4より少し多い。それを解説して、今回は締めよう。

コンストラクタVerletStick()には、以下のコード6のように、引数をふたつ加えてクラスの4つのプロパティすべてが渡せるようにした。とはいえ、後のふたつはつねに与えなくともよい。そのために、デフォルト値を定めた。引数が渡されなかった場合や数値が適切でないときは、デフォルト値が用いられる[2]⁠。なお、クラスの他のふたつのメソッドに変更はない。

コード6 棒を定めるクラスVerletStickのコンストラクタに引数追加
function VerletStick(point0, point1, length, elasticity) {
  if (!elasticity || elasticity > 0.5 || 0 > elasticity) {
    this.elasticity = 0.2;
  } else {
    this.elasticity = elasticity;
  }
  this._point0 = point0;
  this._point1 = point1;
  if (!length || length < 0) {
    this._length = point0.getDistance(point1);
  } else {
    this._length = length;
  }
}
VerletStick.prototype.update = function() {
  var delta = this._point1.subtract(this._point0);
  var distance = delta.getLength();
  var difference = this._length - distance;
  var offsetX = (difference * delta.x / distance)  * this.elasticity;
  var offsetY = (difference * delta.y / distance)  * this.elasticity;
  this._point0.addCoordinates(-offsetX, -offsetY);
  this._point1.addCoordinates(offsetX, offsetY);
};
VerletStick.prototype.render = function(graphics) {
  graphics.beginStroke("black")
  .setStrokeStyle(0.5)
  .moveTo(this._point0.x, this._point0.y)
  .lineTo(this._point1.x, this._point1.y);
};

もうひとつだけ補っておきたいのは、棒の固さのプロパティ(elasticity)のデフォルト値を0.5でなく0.2としたことだ。前述のとおり、0.5では2点間の距離と棒の長さとの誤差は、ふたつの点の位置をそれぞれ半分ずつずらして直ちに補正される。しかし、この後点と棒を増やしていくと、あちらを立てればこちらが立たずという場合が起こり、無理が生ずることもある(その場合は、自然なアニメーションにならない⁠⁠。そこで、誤差を少しずつ解消するように、プロパティのデフォルト値は小さな値にした。

さて、今回使った運動の仕組みでは、座標の移動と位置関係や動く範囲を定めるだけで、跳ね返りの速度や方向など考えることなく複数のオブジェクトをつなげて弾力のある動きができた。次回はすでに示唆したとおり、点と棒の数を増やそう。

おすすめ記事

記事・ニュース一覧