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

第10回ドラッグの軌跡を滑らかな曲線で描く

前回は、BoxBlurFilterによりぼかしたイメージのインスタンスに、AlphaMaskFilterでマスクしたもとのイメージのインスタンスを重ね、マウスのドラッグでアルファマスクを描いた(EaselJSALPHAMASK FILTER参照⁠⁠。参考までに、jsdo.itのサンプルコードを改めて掲げる。この表現そのものは基本的に変えない。今回は、連続する座標を滑らかな軌跡で描く手法について考えたい。

軌跡の描き方をお題と比べる

まず、前掲jsdo.itのコードのもととなった前回の第9回コード2がどのようにマウスポインタの座標の軌跡を描いたか、確かめておこう。ステージ上でマウスボタンを押すStage.stagemousedownイベントでドラッグが始まり(startWipe()⁠⁠、ドラッグしている間のアルファマスクのアニメーションはTicker.tickイベントのリスナー(wipe())が行う。

ドラッグを始めるリスナー関数(startWipe())は、マウスポインタの座標をPointオブジェクトの変数(oldPoint)に納め、描画するGraphicsオブジェクト(wipingShape)の準備を整える。アニメーションのリスナー関数(wipe())は、変数(oldPoint)の座標と今現在のマウスポインタの座標(mouseXとmouseY)を、Graphics.lineTo()メソッドにより直線で結ぶ。そして、新たなマウスポインタの座標で、変数(oldPoint)の値を書き替える。

var wipingShape;

var oldPoint = new createjs.Point();

function startWipe(eventObject) {
  var mousePoint = getMousePoint();
  oldPoint.x = mousePoint.x;
  oldPoint.y = mousePoint.y;

  wipingShape.graphics
  .setStrokeStyle(radius * 2, "round", "round");
}

function wipe(eventObject) {

  var mousePoint = getMousePoint();
  var mouseX = mousePoint.x;
  var mouseY = mousePoint.y;
  wipingShape.graphics
  .beginStroke(createjs.Graphics.getRGB(0x0, 0.15))
  .moveTo(oldPoint.x, oldPoint.y)
  .lineTo(mouseX, mouseY);
  oldPoint.x = mouseX;
  oldPoint.y = mouseY;

}

お題のALPHAMASK FILTERも、ドラッグを始めるリスナー関数(handleMouseDown())とアニメーションのリスナー関数(handleMouseMove())で軌跡を描いている[1]⁠。マウスポインタの座標を変数(oldPt)にとって用いているのは第9回コード2と同じだ。しかし、大きく異なることがふたつある。第1に、Graphics.curveTo()メソッドで曲線を描いていることだ。第2に、もうひとつ座標を変数(oldMidPt)に納めている。これは、今現在のマウスポインタの座標と古い座標の平均、つまり中点を計算している。⁠>> 1」「/ 2」とほぼ同じと捉えてよい[2]⁠。

細かな中身はこれから順を追って説明する。だから、ふたつのコードの間に違いがふたつあるということだけ、取りあえず頭に留めてほしい。なお、このコードには実は重大な問題がある。それは、本稿の最後に明かしたい。

var drawingCanvas;
var oldPt;
var oldMidPt;

function handleMouseDown(event) {
  oldPt = new createjs.Point(stage.mouseX, stage.mouseY);
  oldMidPt = oldPt;

}

function handleMouseMove(event) {

  var midPoint = new createjs.Point(oldPt.x + stage.mouseX >> 1, oldPt.y+stage.mouseY >> 1);
  drawingCanvas.graphics.setStrokeStyle(40, "round", "round")
  .beginStroke("rgba(0,0,0,0.15)")
  .moveTo(midPoint.x, midPoint.y)
  .curveTo(oldPt.x, oldPt.y, oldMidPt.x, oldMidPt.y);
  oldPt.x = stage.mouseX;
  oldPt.y = stage.mouseY;
  oldMidPt.x = midPoint.x;
  oldMidPt.y = midPoint.y;

}

2次ベジエ曲線を描く

Graphics.curveTo()メソッドは、内部的にはGraphics.quadraticCurveTo()メソッドを参照する。ActionScriptと同じ名前のメソッドを設けることで、ユーザーになじみやすくした。⁠2次ベジエ」⁠Quadratic Bezier)の曲線を描くメソッドだ。以降はGraphics.quadraticCurveTo()メソッドで代表する。2次ベジエは、始点と終点のふたつの座標に加えて、ひとつのコントロールポイントで曲線を定める図1左図⁠⁠。Adobe Illustratorなどのベクターグラフィックスを描くアプリケーションでおなじみのベジエ曲線は、コントロールポイントがふたつある「3次ベジエ」⁠Cubic Bezier)図1右図⁠⁠。

図1 2次ベジエと3次ベジエの曲線のつくり
図1 2次ベジエと3次ベジエの曲線のつくり
ActionScript 3.0コンポーネントリファレンスガイド「Graphics」curveTo()メソッドの項より引用。

2次ベジエは、3次ベシエよりコントロールポイントがひとつ少ない分、プログラムの扱いは楽だ。2次ベジエのコントロールポイントは、両端の座標から曲線の接線を延ばし、その交点に置く図2⁠。Graphics.quadraticCurveTo()メソッドの引数には、コントロールポイントのxy座標と曲線で結ぶ先のxy座標を渡す。

Graphicsオブジェクト.quadraticCurveTo(コントロールx座標, コントロールy座標, 終点x座標, 終点y座標)
図2 コントロールポイントと両端を結ぶ直線は曲線の接線になる
図2 コントロールポイントと両端を結ぶ直線は曲線の接線になる
jsdo.itシミュレーションサンプル

曲線の設定や引き始めの座標決めなどは、Graphics.lineTo()メソッドで直線を描く場合と基本的に同じだ。Graphics.quadraticCurveTo()メソッドの文法や曲線の描き方について、詳しくはEaselJSのGraphicクラスで2次ベジエ曲線を描くをお読みいただきたい。

Graphicsオブジェクト.beginStroke(カラー)
.setStrokeStyle(各スタイル, …)
.moveTo(始点x座標, 始点y座標)
.quadraticCurveTo(コントロールx座標, コントロールy座標, 終点x座標, 終点y座標);

さて、2次ベジエ曲線を描くには、始点と終点(これらを合わせて「アンカーポイント」と呼ぶことにする)のほかに、コントロールポイントを与えなければならない。すると、連続した座標をひとつおきでアンカーポイントとコントロールポイントに振分けることが思いつく。けれど、ジグザグの軌跡については、滑らかな曲線にはならない図3⁠。

図3 ひとつおきにコントロールポイントを定めたのではジグザグの座標の軌跡には角が生じる
図3 ひとつおきにコントロールポイントを定めたのではジグザグの座標の軌跡には角が生じる

そこで、ひとつ工夫を加える。連続した座標は、すべてコントロールポイントにしてしまう。そして、座標の中点をアンカーポイントとして結ぶのだ図4⁠。こうすれば、軌跡は滑らかな曲線になる[3]⁠。ただし、軌跡の曲線が座標の上を通過しないことには注意しておこう。それでも、座標の変化が緩やかなら軌跡はそれらの近くをとおり、変化が激しくても滑らかな曲線を描く。

図4 座標はコントロールポイントとして中点をアンカーポイントに定める
図4 座標はコントロールポイントとして中点をアンカーポイントに定める
jsdo.itシミュレーションサンプル

前項で確かめたお題の「ALPHAMASK FILTER」で座標の中点を求めていたのは、このアンカーポイントの座標を決めるためだったのだ。

2次ベジエ曲線で滑らかな軌跡を描く

それでは、前回の第9回コード2に手を加えて、2次ベジエ曲線でマウスポインタの軌跡を滑らかに描いてみよう。マウスポインタの座標を納める変数(oldPoint)に加えて、中点のアンカーポイントの座標も変数(oldMidPoint)にとっておく。マウスボタンを押してドラッグを始めるリスナー関数(startWipe())は、これらふたつの変数に取りあえずマウスポインタの座標を与える。

そして、ドラッグのアニメーションのリスナー関数(wipe())は、変数(oldPoint)の座標と今現在のマウスポインタの座標の中点(midXとmidY)を求める。そして、変数のマウスポインタ座標(oldXとoldY)をコントロールポイントとして、中点座標にGraphics.quadraticCurveTo()メソッドで2次ベジエ曲線を描く。最後に、ふたつの変数のマウスポインタ座標と中点座標を新たな値に書替える。

var oldPoint = new createjs.Point();
var oldMidPoint = new createjs.Point();

function startWipe(eventObject) {
  var mousePoint = getMousePoint();
  oldMidPoint.x = oldPoint.x = mousePoint.x;
  oldMidPoint.y = oldPoint.y = mousePoint.y;

}
function wipe(eventObject) {

  var mousePoint = getMousePoint();
  var mouseX = mousePoint.x;
  var mouseY = mousePoint.y;
  var oldX = oldPoint.x;
  var oldY = oldPoint.y;
  var midX = (oldX + mouseX) / 2;
  var midY = (oldY + mouseY) / 2;
  wipingShape.graphics
  .beginStroke(createjs.Graphics.getRGB(0x0, 0.15))
  // .moveTo(oldPoint.x, oldPoint.y)
  .moveTo(oldMidPoint.x, oldMidPoint.y)
  // .lineTo(mouseX, mouseY);
  .quadraticCurveTo(oldX, oldY, midX, midY);
  oldPoint.x = mouseX;
  oldPoint.y = mouseY;
  oldMidPoint.x = midX;
  oldMidPoint.y = midY;

}

書き直したスクリプト全体は、つぎのコード1のとおりだ。これで、マウスポインタの軌跡が滑らかな曲線で描かれる図5⁠。これで、ひとまずお題はでき上がりだ。jsdo.itにもサンプルコードを掲げた。

コード1 ドラッグする軌跡でアルファマスクを描く
var stage;
var wipingShape;
var imageBitmap;
var blurBitmap;
var imageSize = new createjs.Point();
var radius = 10;
var bitmapPoint = new createjs.Point();
var oldPoint = new createjs.Point();
var oldMidPoint = new createjs.Point();
var isDrawing;
var cursor;
function initialize() {
  var canvasElement = document.getElementById("myCanvas");
  var canvasSize = new createjs.Point(canvasElement.width, canvasElement.height);
  stage = new createjs.Stage(canvasElement);
  var loader = new createjs.LoadQueue(false);
  loader.addEventListener("fileload", draw);
  loader.loadFile({
    src: "images/image.png", 
    data: canvasSize
    });
}
function draw(eventObject) {
  var image = eventObject.result;
  var canvasSize = eventObject.item.data;
  var imageWidth = imageSize.x = image.width;
  var imageHeight = imageSize.y = image.height;
  var nX = bitmapPoint.x = (canvasSize.x - imageWidth) / 2;
  var nY = bitmapPoint.y = (canvasSize.y - imageHeight) / 2;
  stage.addEventListener("stagemousedown", startWipe);
  stage.addEventListener("stagemouseup", stopWipe);
  stage.enableMouseOver();
  wipingShape = new createjs.Shape();
  blurBitmap = createBitmap(image, nX, nY);
  blurBitmap.filters = [new createjs.BoxBlurFilter(15, 15, 2)];
  blurBitmap.cache(0, 0, imageWidth, imageHeight);
  blurBitmap.alpha = 0.8;
  imageBitmap = createBitmap(image, nX, nY);
  createCursor();
  updateCacheImage(false);
  createjs.Ticker.addEventListener("tick", wipe);
}
function createBitmap(image, nX, nY) {
  var myBitmap = new createjs.Bitmap(image);
  myBitmap.x = nX;
  myBitmap.y = nY;
  stage.addChild(myBitmap);
  return myBitmap;
}
function createCursor() {
  cursor = new createjs.Shape();
  cursor.graphics
  .beginFill("white")
  .drawCircle(0, 0, radius);
  cursor.cursor = "pointer";
  cursor.alpha = 0.3;
  stage.addChild(cursor);
}
function startWipe(eventObject) {
  var mousePoint = getMousePoint();
  oldMidPoint.x = oldPoint.x = mousePoint.x;
  oldMidPoint.y = oldPoint.y = mousePoint.y;
  isDrawing = true;
  wipingShape.graphics
  .setStrokeStyle(radius * 2, "round", "round");
}
function wipe(eventObject) {
  cursor.x = stage.mouseX;
  cursor.y = stage.mouseY;
  if (isDrawing) {
    var mousePoint = getMousePoint();
    var mouseX = mousePoint.x;
    var mouseY = mousePoint.y;
    var oldX = oldPoint.x;
    var oldY = oldPoint.y;
    var midX = (oldX + mouseX) / 2;
    var midY = (oldY + mouseY) / 2;
    wipingShape.graphics
    .beginStroke(createjs.Graphics.getRGB(0x0, 0.15))
    .moveTo(oldMidPoint.x, oldMidPoint.y)
    .quadraticCurveTo(oldX, oldY, midX, midY);
    oldPoint.x = mouseX;
    oldPoint.y = mouseY;
    oldMidPoint.x = midX;
    oldMidPoint.y = midY;
    updateCacheImage(true);
  } else {
    stage.update();
  }
}
function stopWipe(event) {
  isDrawing = false;
}
function getMousePoint() {
  var mouseX = stage.mouseX - bitmapPoint.x;
  var mouseY = stage.mouseY - bitmapPoint.y;
  return new createjs.Point(mouseX, mouseY);
}
function updateCacheImage(update) {
  updateCache(update, wipingShape);
  var maskFilter = new createjs.AlphaMaskFilter(wipingShape.cacheCanvas);
  imageBitmap.filters = [maskFilter];
  updateCache(update, imageBitmap);
  stage.update();
}
function updateCache(update, instance) {
  if (update) {
    instance.updateCache();
  } else {
    instance.cache(0, 0, imageSize.x, imageSize.y);
  }
}
図5 マウスポインタの軌跡が滑らかな曲線でアルファマスクに描かれる
図5 マウスポインタの軌跡が滑らかな曲線でアルファマスクに描かれる

本当に滑らかな曲線が描けているのか

これでは腑に落ちない読者がおられよう。前掲コード1を試しても、第9回コード2との違いが感じられない。本当に滑らかな曲線が描けているのか確かめるには、コードにつぎのような手を加えればよい。まず、フレームレートが高いと座標が細かくつなげられるため、曲線に近く見える。Ticker.setFPS()メソッドを使えば、フレームレートが下げられる。つぎに、描く線が太く、また角や端は丸められている。これを細く、シャープにすれば角が目立つ。さらに、線が見やすいように、アルファは1(デフォルト)にしよう。

function draw(eventObject) {

  createjs.Ticker.addEventListener("tick", wipe);
  createjs.Ticker.setFPS(4);
}

function startWipe(eventObject) {

  wipingShape.graphics
  // .setStrokeStyle(radius * 2, "round", "round");
  .setStrokeStyle(2);
}
function wipe(eventObject) {

  wipingShape.graphics
  // .beginStroke(createjs.Graphics.getRGB(0x0, 0.15))
  .beginStroke(createjs.Graphics.getRGB(0x0))
  .moveTo(oldMidPoint.x, oldMidPoint.y)
  .quadraticCurveTo(oldX, oldY, midX, midY);

}

前掲jsdo.itのコードでも、これらのステートメントはコメントアウトして加えてある。興味がある読者は、ぜひお試しいただきたい。マウスをジクザグに動かしても、軌跡は曲線で描かれるはずだ。

しかし、逆にいえば、そこまでしなければ差はわからない。このお題についていえば、第9回コード2ででき上がりとしても差し支えなかったということになる。それでも、滑らかな軌跡の描き方は、覚えておいて損はない。ということで、今回あえてご紹介した次第だ[3]⁠。

もうひとつ謎が残っていた。お題の「ALPHAMASK FILTER」「重大な問題」だ。このコードも前掲コード1と同じく、マウスポインタと中点の座標を変数(oldPtとoldMidPt)にとっている。問題は、マウスボタンを押したときのリスナー関数(handleMouseDown())で、変数にPointオブジェクトを代入するステートメントだ。

JavaScriptも含めて多くのプログラミング言語は、変数にはオブジェクトの参照を代入する。つまり、ふたつの変数(oldPtとoldMidPt)は、同じオブジェクトを参照している。そのため、Graphics.curveTo()メソッドに渡したコントロールポイントとアンカーポイントの座標は同じになる。その場合、このメソッドは座標を直線で結ぶことになる。

線を描いた後、新たな座標値を変数に与えるステートメントでも、Pointオブジェクトはつくり直していないので、ふたつの変数は同じオブジェクトの参照をもったままということになる。

var oldPt;
var oldMidPt;

function handleMouseDown(event) {
  oldPt = new createjs.Point(stage.mouseX, stage.mouseY);
  oldMidPt = oldPt;

}

function handleMouseMove(event) {

  drawingCanvas.graphics.setStrokeStyle(40, "round", "round")

  .curveTo(oldPt.x, oldPt.y, oldMidPt.x, oldMidPt.y);
  oldPt.x = stage.mouseX;
  oldPt.y = stage.mouseY;
  oldMidPt.x = midPoint.x;
  oldMidPt.y = midPoint.y;

}

奇しくも、このお題では座標を直線で結んでも、わからないし差し支えないということを示している。まぁ、こういうこともあるだろう。次回は、また新たなお題に取り組みたい。

おすすめ記事

記事・ニュース一覧