dorivenの日記

気がついたら社会人。気になる技術的なことについて少しずつ書いていけたらと思っております。

【Wonderfl移植計画】AquaTypography 解説編【7作目】

wonderflのFavoriteTop100にランクインしている作品をCreateJSを使って移植しようという計画。

ライセンスはMITなのを確認していますが、
制作者様の方から何かアクションがあった場合は削除させていただきます。

87位 AquaTypography

移植元のFlashソースコード

CreateJSのデモ

色々と用事があったのとcanvasの仕様に苦しんだ結果、かなり解説をするのが遅くなってしまいました。

ようやく今日はこいつの解説が出来るぞ!

全体の構成

このプログラムは以下のとおりの構成となっております。

  • テキストボックスの文字をBitmapDataに描画、ぼかす
  • 文字BitmapDataからパーティクルの経路生成・スプライン補間
  • 時刻から座標を計算・パーティクルの描画

では、それぞれを解説していきましょう。

テキストボックスの文字をBitmapDataに描画、ぼかす

ここで作成するのは白抜きの文字画像である。

こんな感じの奴。

f:id:doriven:20131228022149p:plain

これをどう作っているのかというと、

文字をBitmapDataに描画→ぼかし

という工程を10回繰り返し、最後に白抜きにするために文字を判定描画することでこのような画像になります。

if(_it.text == '') return;
// [白抜き作成]
var bmds = new Array();
var str = _it.value;
// for each 文はブラウザ依存なので今回はfor文で逐次文字を取得する形式を採用
for(var i = 0; i < str.length; ++i)
{
	var c = str[i];
	if(c == ' ' || c == ' ') continue; // [スペースはノーカウント] ここで2重にチェックしているのは全角スペースにも対応するため
	_tf.text = c; // Textクラスにi文字目を代入
	var bmd = textFieldToBitmap(_tf, 4); // Scaleを4倍にしてTextクラスをBitmapDataに変換する
	var blurred = new createjs.BitmapData(null, W, H, 0xffffff); // ブラーフィルターを通すBitmapDataを用意
	// [元のBitmapDataにblurをかけ、さらに元のBitmapDataを描く、というのを繰り返しで文字の周囲を濃くする]
	for(var j = 0; j < 10; ++j)
	{
		// 元のソースではブラーを掛けてから文字を描画していて一回目が無駄になっていたので順番を入れ替えた
		blurred.draw(bmd);
		blurred.applyFilter(blurred, blurred.rect, new createjs.Point(), new createjs.BlurFilter(100, 100));
	}
	// [んノックアウトォ!]
	// んノックアウトォ!
	// canvasのCompositeOperationにinvertがなかったのでこちらで作成
	// ActionScript3.0のBlendMode'invert'は背景(blurred)を反転するので描画元を反転させれば同じ効果が得られる
	// blurred.draw(bmd, NULL, NULL, 'invert');
	invertBitmapData(bmd);
	blurred.draw(bmd); // 白抜きにした文字を描画する
	bmd.dispose();
	bmds.push(blurred);
}

移行時の勘所その1: ActionScript3にはBlendModeで'invert'が用意されていますが、canvasにはそのようなものは無かったのでこちらで反転用の関数を用意しました。
反転をさせた後、drawすることでdraw(src, NULL, NULL, 'invert')と同様の結果になります。

 /**
 * BitmapDataをネガポジ変換する
 * @property {BitmapData} bmd ネガポジ変換されるBitmapData
 */
function invertBitmapData(bmd)
{
	var data = bmd.getPixels(bmd.rect);
	for (var i = 0, l = data.length; i < l; i += 4)
	{
		// Alphaは変更を加えない
		data[i] 	= 255 - data[i];	// R
		data[i + 1]	= 255 - data[i + 1];	// G
		data[i + 2]	= 255 - data[i + 2]; 	// B
	}
	bmd.setPixels(bmd.rect, data);
}

移行時の勘所その2: textFieldToBitmapでASのコードでは(0x00ffffff)で初期化することで、透明な白の画像を作り出していますが、createjs.BitmapDataの仕様上、そのような動作はできないので以下のように書くことでAS3と同様の動作になります。

var bmd = new createjs.BitmapData(null, W, H, 0xffffff);
bmd.clearRect(0, 0, W, H);
var data = bmd.getPixels(bmd.rect);
// 透明な真っ白のbitmapDataに変換する
for (var i = 0, l = data.length; i < l; i += 4)
{
	// Alphaへの変更は加えない
	data[i] 	= 255;	// R
	data[i + 1] 	= 255;	// G
	data[i + 2] 	= 255; 	// B
}
文字BitmapDataからパーティクルの経路生成・スプライン補間

ここでやっている処理は先程の画像に対して、黒い部分を画像毎に次の移動座標点として採択し続ける。

画面外の座標点 → n個(文字数)の黒い部分の座標点 → 画面外の座標点

という計n + 2の座標点が生成され、そこをスプライン補間で滑らかに描画出来るように計算する。

スプライン補間を説明しているとこの記事の量が大変なことになるし、自分もそんなに詳しく説明できないので下のリンクを参照することをおすすめします。

簡略化した3次スプライン曲線の生成方法

この事前処理により、この後に行われる描写で滑らかな動きを実現することが可能になります。

// [パーティクルの経路をつくる]
// [できた白抜きの黒い部分を経由するように]
_particles = new Array();
for(i = 0; i < N; ++i)
{
	var xs = new Array();
	var ys = new Array();
	var r = (W + H) / 2; // 画面大きさの平均
	var theta = Math.random() * 2 * Math.PI; // 0 ~ 2 * PI
	// [初期値はstageの外]
	// ステージの外で円状態に配置されるため、thetaでその角度をランダムに決定し、配置している
	xs.push(W / 2 + r * Math.cos(theta));
	ys.push(H / 2 + r * Math.sin(theta));
	// [各BitmapDataに対して座標をひとつきめる]
	for(var j = 0; j < bmds.length; ++j)
	{
		bmd = bmds[j];
		while(true)
		{
			// 色をキャンバスの中から1ピクセルをランダムに選択し、その色が黒に近ければ採択する
			var x = Math.random() * W;
			var y = Math.random() * H;
			var pc = bmd.getPixel(x, y) & 0xff;
			// [黒いほど採用しやすい]
			// 白の場合はpcが255に近く、黒の場合はpcが0に近い
			// pcが255の場合は右式は0未満の負数になるため、採用されることはなく
			// 最低でもpcが210未満である必要がある
			if(Math.random() < 1 - pc / 210) break;
		}
		xs.push(x);
		ys.push(y);
	}
	
	// [最後もstageの外]
	xs.push(W / 2 + r * Math.cos(theta));
	ys.push(H / 2 + r * Math.sin(theta));
	// [スプラインの係数を計算して格納]
	// ちなみにここで行われる計算は補間法のひとつである3次スプライン補間を用いている。
	// 3次スプライン補間とは離散的な値の前後と合わせて3つの値を元に計算することで実現される。
	// これにより、座標点間がより細かくプロットされ、なめらかな動きを実現することが可能.
	// ここの手法ではランダムで決められた画面外の最初と最後の座標の間に、文字数分の座標があり、
	// この間をスプライン補間を使って埋めていく。
	// 蛇足だが、y = ax + b で a の傾きを求めて二点間の y の値を求める方法を2次スプライン補間とも言う。
	var xCoes = Spline.calcCoordinate(xs);
	var yCoes = Spline.calcCoordinate(ys);
	// スプライン補間によって各X,Y座標から得られたa,b,c,dという4つの変数を持ったパーティクルを生成する
	_particles.push(new Particle(xCoes, yCoes));
}
時刻から座標を計算・パーティクルの描画

ここの処理は事前処理で行ったスプライン補間の係数を用いて、時刻tを指定してあげると、なめらかな移動をする座標点を計算して、描画します。

描画したピクセルをColorTransformを使うことで徐々に黒くし、時刻によって色をHSV色空間のHueを回転させるようにすることで残像・色の変化を表現しています。

移行時の勘所その3: どうやら画面外に出るとx方向のみ負になった場合はスクリーンラッピングされて描画されました。

今回、最も詰まった場所でありおそらく移行時にcreatejs.BitmapDataを使う上での最大の勘所4:

createjs.BitmapData.updateContext()は最低でも1ピクセルは反映されていないと実行されてないようになっている模様。

ここに気づくの本当に時間が掛かった。

なんで途中で描画ストップするんだよ!しかも数秒放置すると動き始めるし!という感じになってました。

ここら辺で詰まる辺り、やはり一度canvasの基礎APIからやった方がよかったりするのかも。

今後の学習として要検討。

/**
* パーティクルをスプライン補間を使って滑らかに毎フレーム描画し続ける
*/
function onEnterFrame()
{
	// [パーティクルの描画]
	for(var i = 0; i < _particles.length; ++i)
	{
		p = _particles[i];
		// X座標とY座標をスプライン補間で求める
		var px = Spline.calc(p.xCoes, _t);
		var py = Spline.calc(p.yCoes, _t);
		// !勘所! CreateJSの仕様でスクリーンラップされるので画面外のピクセルは条件分岐で削除するように変更
		if(px < 0 || px >= W || py < 0 || py >= H) continue;
		_canvas.setPixel(px, py, 0xffffff);
	}
	// !勘所! createjs.BitmapData.updateContext()は最低でも1ピクセルは反映されていないと実行されてないようになっている模様.
	_canvas.setPixel(0, 0, 0x000000);
	_canvas.updateContext();

	// [色減衰]
	// [減衰度をまわすことによって色調を変える]
	_canvas.colorTransform(_canvas.rect,
		new createjs.ColorTransform(
			0.96 + 0.02 * Math.sin(_t + _offset),
			0.96 + 0.02 * Math.sin(_t + Math.PI * 2 / 3 + _offset),
			0.96 + 0.02 * Math.sin(_t + Math.PI * 4 / 3 + _offset),
			1
		)
	);

	_t += 0.009; // 時間を徐々にずらし、細かく動作させていく
	if(_t >= _tlim)
	{
		createjs.Ticker.removeEventListener('tick', onEnterFrame);
		createjs.Ticker.addEventListener('tick', stage);
	}
	stage.update();
}
最後に

ここまで読んで頂き誠にありがとうございました。

今回は色々とcanvasのAPIに苦戦させられました。

createjs.BitmapDataを使っていくことで、移行時の勘所を発見し、これからCreateJSとBitmapDataライブラリが繁栄することを信じて、これからも移植を続けていきたいと思います。

前回に比べて大分コードを変更したので、コードを下に全部載せておきます。

// EaselJS 0.7
// BitmapData 1.0.0
// -- http://kudox.jp/java-script/createjs-easeljs-bitmapdata
var stage;

var W; // {const int} canvasの横幅
var H; // {const int} canvasの縦幅

var N = 2000;	// {const int} パーティクルの数

var _it;	// form:textのDOM要素を格納する変数
var _submit;	// form:submitのDOM要素を格納する変数
var _tf;	// 本来ならテキストフィールドを代入するが、今回は直接Textを代入する

var _particles; // {Array} Particleクラスを格納する配列
var _tlim;	// [終了時刻]
var _canvas;	// [ここにかく]
var _t;		// [経過時間]
var _offset;	// [色位相のオフセット]


// ----------------------------
// 		Sprineクラスの定義
// ----------------------------
/**
* @property {int} xCoes x座標のスプライン補間の結果
* @property {int} yCoes y座標のスプライン補間の結果
*/
var Particle = (function()
{
	function Particle(xCoes, yCoes)
	{
		this.xCoes = xCoes;
		this.yCoes = yCoes;
	}
	return Particle;
})();

// [@see http://www5d.biglobe.ne.jp/~stssk/maze/spline.html]
// StaticなSpline関数を定義するために関数オブジェクトだけ用意する
var Spline = (function()
{
	function Spline()
	{}

	return Spline;
})();

// static Sprine::calc
// @param {Object} cs 結果を得るために必要なスプライ補間の結果
// @param {Number} t 取得したい状態時間
Spline.calc = function(cs, t)
{
	var p = Math.floor(t);		// 離散時間に落とす
	if(p >= cs.a.length) --p;	// 配列の最大値未満にする(p = cs.a.length - 1でもいいかも?)
	if(p < 0) p = 0;		// 初期値にする
	var dt = t - p;			// x - x_j と同様の計算を行う
	return cs.a[p] + (cs.b[p] + (cs.c[p] + cs.d[p] * dt) * dt) * dt;
};

/**
* @see のページの"n次元空間上のスプライン曲線"を参照
* static Sprine::calcCoordinate
* @param {Array} a 連続したX座標、もしくは連続したY座標
*/ 
Spline.calcCoordinate = function(a)
{
	var n = a.length;

	var b = Array();
	var c = Array();
	var d = Array();
	var w = Array();

	var i;

	c.push(0);
	for(i = 1; i < n - 1; ++i)
	{
		c.push(3 * (a[i + 1] - 2 * a[i] + a[i - 1]));
	}
	c.push(0);

	w.push(0);

	for(i = 1; i < n - 1; ++i)
	{
		var l = 4.0 - w[i - 1];
		c[i] = (c[i] - c[i - 1]) / l;
		w.push(1.0 / l);
	}
	for(i = n - 2; i > 0; --i)
	{
		c[i] -= c[i + 1] * w[i];
	}

	for(i = 0; i < n - 1; ++i)
	{
		d.push((c[i + 1] - c[i]) / 3.0);
		b.push(a[i + 1] - a[i] - c[i] - d[i]);
	}
	b.push(0);
	d.push(0);

	return {a : a, b : b, c : c, d : d};
}
// ----------------------------

 window.onload = init;

/**
* この関数にコンストラクタやonFontLoadedの中身を記述する
*/
function init()
{
	stage = new createjs.Stage('canvas');
	W = stage.canvas.width;
	H = stage.canvas.height;

	// コンストラクタ
	// でも中身はほとんどテキストフィールドやボタン、フォントの配置などでHTML側で行う
	_canvas = new createjs.BitmapData(null, W, H, 0x000000);
	stage.addChild(new createjs.Bitmap(_canvas.canvas));
	_it = document.getElementById('text');
	_it.value = 'jsdo.it';
	
	// ボタンDOM要素の取得と設定
	_submit = document.getElementById('go');
	_submit.onclick = onSubmit;
	//_submit.enable = false;

	// onFontLoaded
	// テキストフィールドはCreateJSにはないので直接TEXTをBitmapにして扱う
	// ActionScripのTextFieldはデフォルトで100x100の大きさをサポートしているので、100pxにフォントサイズを設定している
	_tf = new createjs.Text("12", "100px Aqua", "#000000");
	stage.addEventListener('stagemousedown', onClick);
	// canvasを黒の背景で初期化して開始時のチラツキ防止
	stage.update();

	createjs.Ticker.setFPS(60);

	reset();
}

/**
* submitボタンDOM要素に設定するクリックイベント
*/
function onSubmit(e)
{
	if(_it.value.length == 0) return;
	console.log('submit');
	reset();
}

/**
* canvasのDOM要素に設定するクリックイベント
*/
function onClick(e)
{
	start();
}

/**
* クリックして何度も呼び出される可能性のある初期化イベント。
*/
function reset()
{
	if(_it.text == '') return;

	// [白抜き作成]
	var bmds = new Array();
	var str = _it.value;
	// for each 文はブラウザ依存なので今回はfor文で逐次文字を取得する形式を採用
	for(var i = 0; i < str.length; ++i)
	{
		var c = str[i];
		if(c == ' ' || c == ' ') continue; // [スペースはノーカウント] ここで2重にチェックしているのは全角スペースにも対応するため
		_tf.text = c; // Textクラスにi文字目を代入
		var bmd = textFieldToBitmap(_tf, 4); // Scaleを4倍にしてTextクラスをBitmapDataに変換する
		var blurred = new createjs.BitmapData(null, W, H, 0xffffff); // ブラーフィルターを通すBitmapDataを用意

		// [元のBitmapDataにblurをかけ、さらに元のBitmapDataを描く、というのを繰り返しで文字の周囲を濃くする]
		for(var j = 0; j < 10; ++j)
		{
			// 元のソースではブラーを掛けてから文字を描画していて一回目が無駄になっていたので順番を入れ替えた
			blurred.draw(bmd);
			blurred.applyFilter(blurred, blurred.rect, new createjs.Point(), new createjs.BlurFilter(100, 100));
		}
		// [んノックアウトォ!]
		// んノックアウトォォォ!
		// canvasのCompositeOperationにinvertがなかったのでこちらで作成
		// ActionScript3.0のBlendMode'invert'は背景(blurred)を反転するので描画元を反転させれば同じ効果が得られる
		// blurred.draw(bmd, NULL, NULL, 'invert');
		invertBitmapData(bmd);
		blurred.draw(bmd);
		bmd.dispose();

		bmds.push(blurred);
	}

	// [パーティクルの経路をつくる]
	// [できた白抜きの黒い部分を経由するように]
	_particles = new Array();
	for(i = 0; i < N; ++i)
	{
		var xs = new Array();
		var ys = new Array();
		var r = (W + H) / 2; // 画面大きさの平均
		var theta = Math.random() * 2 * Math.PI; // 0 ~ 2 * PI

		// [初期値はstageの外]
		// ステージの外で円状態に配置されるため、thetaでその角度をランダムに決定し、配置している
		xs.push(W / 2 + r * Math.cos(theta));
		ys.push(H / 2 + r * Math.sin(theta));

		// [各BitmapDataに対して座標をひとつきめる]
		for(var j = 0; j < bmds.length; ++j)
		{
			bmd = bmds[j];
			while(true)
			{
				// 色をキャンバスの中から1ピクセルをランダムに選択し、その色が黒に近ければ採択する
				var x = Math.random() * W;
				var y = Math.random() * H;
				var pc = bmd.getPixel(x, y) & 0xff;
				// [黒いほど採用しやすい]
				// 白の場合はpcが255に近く、黒の場合はpcが0に近い
				// pcが255の場合は右式は0未満の負数になるため、採用されることはなく
				// 最低でもpcが210未満である必要がある
				if(Math.random() < 1 - pc / 210) break;
			}
			xs.push(x);
			ys.push(y);
		}
		
		// [最後もstageの外]
		xs.push(W / 2 + r * Math.cos(theta));
		ys.push(H / 2 + r * Math.sin(theta));

		// [スプラインの係数を計算して格納]
		// ちなみにここで行われる計算は補間法のひとつである3次スプライン補間を用いている。
		// 3次スプライン補間とは離散的な値の前後と合わせて3つの値を元に計算することで実現される。
		// これにより、座標点間がより細かくプロットされ、なめらかな動きを実現することが可能.
		// ここの手法ではランダムで決められた画面外の最初と最後の座標の間に、文字数分の座標があり、
		// この間をスプライン補間を使って埋めていく。
		// 蛇足だが、y = ax + b で a の傾きを求めて二点間の y の値を求める方法を2次スプライン補間とも言う。
		var xCoes = Spline.calcCoordinate(xs);
		var yCoes = Spline.calcCoordinate(ys);
		// スプライン補間によって各X,Y座標から得られたa,b,c,dという4つの変数を持ったパーティクルを生成する
		_particles.push(new Particle(xCoes, yCoes));
	}

	// 描画の残りを消すために2秒ではなく5秒追加に変更
	_tlim = bmds.length + 5;

	// [_bmdsは用済み]
	for(i= 0; i < bmds.length; ++i)
	{
		bmds[i].dispose(); // BitmapDataに使われてたデータ領域を解法
	}
	start();
}

/**
* 描画の開始
*/
function start()
{
	_t = 0; // 時間の初期化
	createjs.Ticker.removeEventListener("tick", onEnterFrame);
	createjs.Ticker.addEventListener("tick", onEnterFrame);
	_offset = Math.random() * 2 * Math.PI;
}

/**
* パーティクルをスプライン補間を使って滑らかに毎フレーム描画し続ける
*/
function onEnterFrame()
{
	// [パーティクルの描画]
	for(var i = 0; i < _particles.length; ++i)
	{
		p = _particles[i];
		// X座標とY座標をスプライン補間で求める
		var px = Spline.calc(p.xCoes, _t);
		var py = Spline.calc(p.yCoes, _t);
		// !勘所! CreateJSの仕様でスクリーンラップされるので画面外のピクセルは条件分岐で削除するように変更
		if(px < 0 || px >= W || py < 0 || py >= H) continue;
		_canvas.setPixel(px, py, 0xffffff);
	}
	// !勘所! createjs.BitmapData.updateContext()は最低でも1ピクセルは反映されていないと実行されてないようになっている模様.
	_canvas.setPixel(0, 0, 0x000000);
	_canvas.updateContext();

	// [色減衰]
	// [減衰度をまわすことによって色調を変える]
	_canvas.colorTransform(_canvas.rect,
		new createjs.ColorTransform(
			0.96 + 0.02 * Math.sin(_t + _offset),
			0.96 + 0.02 * Math.sin(_t + Math.PI * 2 / 3 + _offset),
			0.96 + 0.02 * Math.sin(_t + Math.PI * 4 / 3 + _offset),
			1
		)
	);

	_t += 0.009; // 時間を徐々にずらし、細かく動作させていく
	if(_t >= _tlim)
	{
		createjs.Ticker.removeEventListener('tick', onEnterFrame);
		createjs.Ticker.addEventListener('tick', stage);
	}
	stage.update();
}

// [tfを画面の中央に置いてscale倍してBitmapDataに転写]
/**
* TextクラスをBitmapDataに変換する
* @param {Text} tf テキストフォームDOM要素に入力されていた1文字を表示する
* @param {int} scale Textクラスのサイズを何倍にして描画するかの値
* @return {BitmapData} bmd
*/
function textFieldToBitmap(tf, scale)
{
	scale = scale || 1;
	var TEXT_WIDTH = 50, TEXT_HEIGHT = 100;
	var bmd = new createjs.BitmapData(null, W, H, 0xffffff);
	bmd.clearRect(0, 0, W, H);
	var data = bmd.getPixels(bmd.rect);
	// 透明な真っ白のbitmapDataに変換する
	for (var i = 0, l = data.length; i < l; i += 4)
	{
		// Alphaへの変更は加えない
		data[i]		= 255;	// R
		data[i + 1]	= 255;	// G
		data[i + 2]	= 255; 	// B
	}
	var mat = new createjs.Matrix2D();
	mat.scale(scale, scale);
	mat.translate((W - TEXT_WIDTH * scale) / 2, (H  - TEXT_HEIGHT * scale) / 2);
	tf.cache(0, 0, TEXT_WIDTH, TEXT_HEIGHT); // BitmapDataをDispalyObjectとしてdrawするために必要な処理
	bmd.draw(tf, mat);
	return bmd;
}

 /**
 * BitmapDataをネガポジ変換する
 * @property {BitmapData} bmd ネガポジ変換されるBitmapData
 */
function invertBitmapData(bmd)
{
	var data = bmd.getPixels(bmd.rect);
	for (var i = 0, l = data.length; i < l; i += 4)
	{
		// Alphaは変更を加えない
		data[i] 	= 255 - data[i];	// R
		data[i + 1]	= 255 - data[i + 1];	// G
		data[i + 2]	= 255 - data[i + 2]; 	// B
	}
	bmd.setPixels(bmd.rect, data);
}