dorivenの日記

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

【Wonderfl移植計画】Light Burst 解説編【8作目】

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

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

80位 LightBurst

移植元のFlashソースコード

CreateJSのデモ

Dropboxソースコードを保存していたのですが、今日は遠出する用事があり、

編集していたPCと同期が取れなかったので今になって投稿しました。

コードにコメント書いて後は記事書くだけの状態にしてたんですが、

これからは事前に記事も下書きで書いてしまうようにしよう。


今回触ってみての一番感じたのはFlashのBlendModeに相当するものが、canvasのcompositeOperationに用意されていないこと。

GlowFilterを掛けた文字を重ねているのにブレンドされていないので、文字の輝きがない、という問題もあります。

そして、BitmapDataの表現力にはBlendModeも一躍買っているので、個人でライブラリとかを作ってみるといいかも。

それと、BlendModeは基本的にAddしか使用したことがなかったので、

様々なブレンドの式とかも分かって色々と新しいことも学べた。

逆に何でこの処理がこうなっているのか分からない、なんて場面がかなり多くて苦労したし、

正直全てしっかり理解出来てないので分かっている部分だけ解説出来たらと思います。

時間が押しているので投稿後に追記・解説を記載し、今はコメント付きのソースコードだけ

全体の構成

今回は以下の様な構成になっている。

  • 光線を出す位置の抽出
  • フィルタの作成・適応
  • 光線を描く
光線を出す位置の抽出

ソースコードは以下の通り。

	var vTemp = new Array();

	// -------------------

	// [getPixelColor]
	// 画像処理の癖で座標の指定は(x, y)で統一していたので(w, h)というのは新鮮に感じた、というどうでもいい個人的な感想
	// Booleanという型で暗黙のキャストをしていて対応していたのを、きっちり変換
	for(var h = 0; h < TH; ++h)
	{
		for(var w = 0; w < TW; ++w)
		{
			var color = bmd.getPixel(w, h);
			vTemp[h * TW + w] = (color != 0x00000000) ? true : false;
		}
	}

	// [Gain inner Pixels]
	for(h = 0; h < TH; ++h)
	{
		for(w = 0; w < TW; ++w)
		{
			var flg = false;
			var pos = h * TW + w;
			// 画面端なら無条件でフラグを立てる
			if(h == 0 || h == TH - 1 || w == 0 || w == TW - 1)
			{
				flg = vTemp[pos];
			}
			// 画面端でなければ
			else
			{
				//flg = false; // 必要ないと判断し、コメントアウト
				// ピクセルに文字が描画されており
				if(vTemp[pos] == true)
				{
					// ピクセルの4方向全てに文字が描画されていなければ(色が付いていなければ)フラグを立てる
					// ※つまり、文字のエッジ部分を対象にしている
					if(vTemp[pos + TW] + vTemp[pos - TW] + vTemp[pos - 1] + vTemp[pos + 1] < 4)
					{
						flg = true;
					}
				}
			}
			// フラグが立っているならば、位置を記憶する
			// テキストのサイズの半分ずらしているのはBitmapの文字の移動に対応するため
			if(flg)
			{
				vOutline.push(new createjs.Point(w - TW / 2, h - TH / 2));
			}
		}
	}

ここで一番注目して欲しいのは抽出した文字のBitmapData情報からどのような情報を抽出しているのか、という点である。

ここで特徴としている情報は画像(BitmapData)のエッジだ。

そのエッジを抽出するために、四方向にピクセル情報を持ったものがあるかを調べ、全てにピクセルがある場合はエッジではないという判断をするようになっている。

後はエッジの座標を保持するようにしている。

フィルタの作成・適応

ここが一番むずかしいところであり、自分でも分かっていない部分もある。

説明するところも多いが、理解している部分だけ説明し、ほかはソースのコメントを参照して欲しい。

*** 画面全体の変化(カメラの移動効果)

このソースでは_screenという最終的な描画結果を表示するためのオブジェクト。

このBitmapDataをスケール変化させながら、それに見合った座標に動かすことで、

全体のオブジェクトの拡大率も変更することを実現している。

/**
* canvasに表示されるオブジェクトを変形させることでカメラの移動を実現する
*/
function zoomOutScreen()
{
	var s = 1.2;

	_screen.scaleX = _screen.scaleY = s;
	_screen.x = -W * (s - 1) / 2;
	_screen.y = -W * (s - 1) / 2;
	createjs.Tween
		.get(_screen)
		.wait(900)
		.to({scaleX:1, scaleY:1, x:0, y:0, time:2}, 2000, createjs.Ease.quadOut);
}

*** 輝きエフェクト文字のマスク処理

今回は自分の理解度が低いためか、元のFlashのようにGlowFilterを適応したテキストを

上手く表示することが出来なかったが、あの光が移動する表現を実現しているのは

マスク処理を行っているからである。

GlowFilterを掛けたテキストglowTextのプロパティのglowText.maskに楕円のShapeを適応し、

それを毎フレーム右に動かすことで、楕円内に存在するglowTextしか表示されないようになっている。

これと上手く同期するように光の線も描画しているため、美しいエフェクトが再生される。

	glowTextMask = createGlowTextMask();
	// glowTextMask.cacheAsBitmap = true;
	glowText.mask = glowTextMask;
	glowTextMask.scaleX = 1.35; // 円を横に引き伸ばすことえ楕円にする

	// --------------------

	lx += 5;
	if(lx > W)
	{
		lx = 0;
		zoomOutScreen();
	}
	glowTextMask.x = lx;

	// --------------------

/**
* 文字の輝きを一部だけに適応するためのマスクを生成する
* ここで生成されたマスクをずらすことで光が移動している演出をする
* マスクの大きさは文字の高さに合わせるように作られる
* @return {Shape} mask
*/
function createGlowTextMask()
{
	var G_PADDING = 20; // マスクの大きさを文字サイズより少しだけ大きくするためのマージン
	var GW = glowText.getBounds().width + G_PADDING * 2;
	var GH = glowText.getBounds().height + G_PADDING * 2;

	var shape = new createjs.Shape();
	var g = shape.graphics;
	var r = GH / 2;
	// !勘所! 引数の数の違いに注意
	// ASでは透明度を指定する引数が別に用意されているが、CreateJSではその引数が削除されcolor配列に取り込まれている
	g.beginRadialGradientFill(
		[createjs.Graphics.getRGB(0x00, 0x00, 0x00, 1), createjs.Graphics.getRGB(0xFF, 0xFF, 0xFF, 0)],
		[0, 1], 2 * r, 2 * r, 0, -r, -r, r);
	g.drawCircle(0, 0, r);
	g.endFill();

	shape.x = (W - GW) / 2 + r;
	shape.y = (H - GH) / 2 + r;

	return shape;
}
光線を描く

以下のソースでやっていることは、先ほど抽出した文字のエッジ座標を起点にして線を引く。

更に光源(lx)を左から右にずらし、画面中央からの差分を取り、そこから得られた角度から

極座標を求めることで終点を求め、線分を引くという作業を行っている。

	// 位置を右にずらす
	lx += 5;

	// [Drow]
	// Draw
	var g = canvas.graphics;
	g.clear();
	g.beginStroke(createjs.Graphics.getRGB(0xFF, 0xEB, 0x79, 0.3));
	g.setStrokeStyle(1);
	var RANGE = 17;
	var len = vOutline.length;
	for(var i = 0; i < len; ++i)
	{
		var tx = vOutline[i].x + CX;
		// 光の位置の一定の範囲から光線が出るようにするための条件文
		if(tx > lx - RANGE && tx < lx + RANGE || (i % 10 == 1 && tx > lx - RANGE * 5 && tx < lx + RANGE * 5))
		{
			// 中心の位置から現在の光の位置の差分を計算することで、光の線の角度を求める
			var r = Math.atan2(vOutline[i].y, vOutline[i].x + (CX - lx));
			// 光線を引くために角度と長さから終点を求める
			var dp = polar(CX * 3, r); // [このCXは長さ]
			// 線の描画
			g.moveTo(vOutline[i].x + CX, vOutline[i].y + CY);
			g.lineTo(vOutline[i].x + lx + dp.x, vOutline[i].y + CY + dp.y);
		}
	}
	g.endStroke();

最後に

最後まで記事を呼んで頂きありがとうございました。

今回は若干急いでの記事の執筆になり申し訳ございません。

次回以降はCentOSの導入レベルから記事を書いていきたいと思います!

最後に今回のソースを載せておきますね。

// EaselJS 0.7.1
// BitmapData for EaselJS 1.0.0
// @see [http://kudox.jp/java-script/createjs-easeljs-bitmapdata]
// GlowFIlter for EaselJS
// @see [http://kudox.jp/java-script/createjs-easeljs-glowfilter]

var stage;

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

var CX; // {const int} 光の線のx方向の長さ
var CY; // {const int} 光の線のy方向の長さ

var vOutline = new Array();
var lx = 0; // {int} マスクのx座標。これをアニメーションでずらしていき、後ろからの光が移動しているように見える
var canvas; // {Shape} HTMLのcanvasに描画するために使用する 

var container; // {Container} フィルターを適用しないDisplayObjectを管理するためのオブジェクト
var glowContainer; // {Cointer} GlowFilterを適応したDisplayObjectを管理するためのオブジェクト

var myText; // {Text} フィルターを適用しないテキスト
var glowText; // {Text} GlowFilterを適用するテキスト

var _screen; // {Shape} javascripのscreenオブジェクトと名前衝突したので_を付けて回避
var buffer; // {BitmapData} 

var lightBuffer; // {BitmapData} 光の線を描画するためのBitmapData

var glowTextMask; // {Shape} glowTextに掛けるマスク

var blurFilter = new createjs.BlurFilter(12, 35);

window.onload = init;

// CreateJSのPointクラスにpolar関数がなかったのでこちらで追加
/**
* 極座標を求める
* @param {Number} 距離
* @param {Number} ラジアン角
* @return {Point} 極座標
*/
function polar(d, rad)
{
	var x = d * Math.cos(rad);
	var y = d * Math.sin(rad);
	return new createjs.Point(x, y);
};

function init()
{
	stage = new createjs.Stage('canvas');
	W = stage.canvas.width;
	H = stage.canvas.height;
	CX = W / 2;
	CY = H / 2;

	canvas = new createjs.Shape();

	glowContainer = new createjs.Container();
	stage.addChild(glowContainer);

	var overlayRect = createOverlayRect();
	var trimRect = createTrimRect();

	lightBuffer = new createjs.BitmapData(null, W, H, 0x000000);

	// CreateJSではSimpleTextは存在しないのでTextクラスで代用
	myText = new createjs.Text('Light Burst', '46px Georgia', "#98833D"); // 文字の作成 
	glowText = new createjs.Text('Light Burst', '46px Georgia', "#F5DC8D"); // Glowの方は少し明るめな色に設定する
	glowText.filters = [new createjs.GlowFilter(0xFFE8AB, 1, 10, 10, 4, 2), new createjs.BlurFilter(7, 7, 1)];

	var vTemp = new Array();
	// const はgoogleのJavaScriptCoding規約で使うことを推奨していないので使わない
	var TW = myText.getBounds().width;
	var TH = myText.getBounds().height;
	// createJSのfilterはcacheを適用しないと反映されない
	glowText.cache(0, 0, TW, TH);

	myText.x = (W - TW) / 2;
	myText.y = (H - TH) / 2;

	// [Text -> BitmapData]
	// 'Light Burst'という文字をBitmapDataに落としこむ
	var bmd = new createjs.BitmapData(null, TW, TH);
	myText.cache(0, 0, TW, TH);
	bmd.draw(myText);

	// [getPixelColor]
	// 画像処理の癖で座標の指定は(x, y)で統一していたので(w, h)というのは新鮮に感じた、というどうでもいい個人的な感想
	// Booleanという型で暗黙のキャストをしていて対応していたのを、きっちり変換
	for(var h = 0; h < TH; ++h)
	{
		for(var w = 0; w < TW; ++w)
		{
			var color = bmd.getPixel(w, h);
			vTemp[h * TW + w] = (color != 0x00000000) ? true : false;
		}
	}

	// [Gain inner Pixels]
	for(h = 0; h < TH; ++h)
	{
		for(w = 0; w < TW; ++w)
		{
			var flg = false;
			var pos = h * TW + w;
			// 画面端なら無条件でフラグを立てる
			if(h == 0 || h == TH - 1 || w == 0 || w == TW - 1)
			{
				flg = vTemp[pos];
			}
			// 画面端でなければ
			else
			{
				//flg = false; // 必要ないと判断し、コメントアウト
				// ピクセルに文字が描画されており
				if(vTemp[pos] == true)
				{
					// ピクセルの4方向全てに文字が描画されていなければ(色が付いていなければ)フラグを立てる
					// ※つまり、文字のエッジ部分を対象にしている
					if(vTemp[pos + TW] + vTemp[pos - TW] + vTemp[pos - 1] + vTemp[pos + 1] < 4)
					{
						flg = true;
					}
				}
			}
			// フラグが立っているならば、位置を記憶する
			// テキストのサイズの半分ずらしているのはBitmapの文字の移動に対応するため
			if(flg)
			{
				vOutline.push(new createjs.Point(w - TW / 2, h - TH / 2));
			}
		}
	}

	// フィルタを掛けてないテキストの上に、フィルタを掛けたテキストを重ねる
	glowText.x = myText.x;
	glowText.y = myText.y;
	// cacheはcanvasの機能を用いて作成
	//glowText.cacheAsBitmap = true;
	glowText.updateCache();

	glowTextMask = createGlowTextMask();
	// glowTextMask.cacheAsBitmap = true;
	glowText.mask = glowTextMask;
	glowTextMask.scaleX = 1.35; // 円を横に引き伸ばすことえ楕円にする
	// !勘所! マスクは表示リストから省く
	// ASではmaskプロパティに代入されたDisplayObjectは表示リストから自動的に削除してくれたが、
	// CreateJSではそのような処理は行ってくれないので明示的に表示リストから削除する必要がある
	// 下に記述したどれかひとつでも実行すれば問題ない
	glowTextMask.visible = false; // 表示リストには入れたままだが、表示しないようにする
	glowTextMask.alpha = 0; // 透明度を上げて表示しないようにする
	// glowContainer.addChild(); そもそも表示リストに追加しない
	var r = (glowText.getBounds().height + 40) / 2; // 自動的にBoundsを計算してくれないのでこちらで適当な値を設定
	glowTextMask.cache(-r, -r, r, r);

	glowContainer.addChild(glowText)
	// glowContainer.blendMode = BlendMode.LAYER;
	// canvasではcompositeOperationにBlendMode.LAYERと同様の動作をするものがなかったので諦めた
	// 同様の処理を実現したいなら自分で実装するしかなさそう

	// containerが描画するオブジェクトを保持する
	// これにレイヤのようにオブジェクトを重ねることで複雑な表現を行っている
	// ここらへんの理解が曖昧なので話半分に聞いてもらえる助かる
	container = new createjs.Container();
	stage.addChild(container);
	container.addChild(glowContainer); // 上でもaddChildしているがどうしてなのか分からなかった
	container.addChild(new createjs.Bitmap(lightBuffer.canvas));
	container.addChild(myText);
	//container.addChild(overlayRect); 中心ほど光が強くなるように作用させる
	//container.addChild(trimRect); 光線の始端と終端の光を弱くなるように作用させる
	// DOM要素のcanvas上に表示する役目は_screenにdrawすることで実現するので不可視状態にする
	container.visible = false;

	// 実際にDOM要素のcanvas上に表示されるBitmapData
	// このBitmapDataにdrawすることで、表示に反映される
	buffer = new createjs.BitmapData(null, W, H, 0x000000);
	_screen = new createjs.Bitmap(buffer.canvas);
	stage.addChild(_screen);

	createjs.Ticker.addEventListener('tick', xAnimation);
	createjs.Ticker.setFPS(60);
	zoomOutScreen();
}

function xAnimation()
{
	// 位置を右にずらして
	lx += 5;
	if(lx > W)
	{
		lx = 0;
		zoomOutScreen();
	}
	glowTextMask.x = lx;

	// [Drow]
	// Draw
	var g = canvas.graphics;
	g.clear();
	g.beginStroke(createjs.Graphics.getRGB(0xFF, 0xEB, 0x79, 0.3));
	g.setStrokeStyle(1);
	var RANGE = 17;
	var len = vOutline.length;
	for(var i = 0; i < len; ++i)
	{
		var tx = vOutline[i].x + CX;
		// 光の位置の一定の範囲から光線が出るようにするための条件文
		if(tx > lx - RANGE && tx < lx + RANGE || (i % 10 == 1 && tx > lx - RANGE * 5 && tx < lx + RANGE * 5))
		{
			// 中心の位置から現在の光の位置の差分を計算することで、光の線の角度を求める
			var r = Math.atan2(vOutline[i].y, vOutline[i].x + (CX - lx));
			// 光線を引くために角度と長さから終点を求める
			var dp = polar(CX * 3, r); // [このCXは長さ]
			// 線の描画
			g.moveTo(vOutline[i].x + CX, vOutline[i].y + CY);
			g.lineTo(vOutline[i].x + lx + dp.x, vOutline[i].y + CY + dp.y);
		}
	}
	g.endStroke();

	lightBuffer.fillRect(lightBuffer.rect, 0); // 光線を消す
	canvas.cache(0, 0, W, H);
	// 光線の描画
	lightBuffer.draw(canvas);
	// 2回ブラーフィルターを適用することで光線にぼかしを入れる
	lightBuffer.applyFilter(lightBuffer, lightBuffer.rect, new createjs.Point(), blurFilter);
	lightBuffer.applyFilter(lightBuffer, lightBuffer.rect, new createjs.Point(), blurFilter);

	// 実際に画面に表示されるBitmapDataに書き込む
	buffer.fillRect(buffer.rect, 0);
	container.cache(0, 0, container.getBounds().width, container.getBounds().height);
	buffer.draw(container, null, null, null, null, true);

	stage.update();
}

/**
* canvasに表示されるオブジェクトを変形させることでカメラの移動を実現する
*/
function zoomOutScreen()
{
	var s = 1.2;

	_screen.scaleX = _screen.scaleY = s;
	_screen.x = -W * (s - 1) / 2;
	_screen.y = -W * (s - 1) / 2;
	createjs.Tween
		.get(_screen)
		.wait(900)
		.to({scaleX:1, scaleY:1, x:0, y:0, time:2}, 2000, createjs.Ease.quadOut);
}

/**
* 文字の輝きを一部だけに適応するためのマスクを生成する
* ここで生成されたマスクをずらすことで光が移動している演出をする
* マスクの大きさは文字の高さに合わせるように作られる
* @return {Shape} mask
*/
function createGlowTextMask()
{
	var G_PADDING = 20; // マスクの大きさを文字サイズより少しだけ大きくするためのマージン
	var GW = glowText.getBounds().width + G_PADDING * 2;
	var GH = glowText.getBounds().height + G_PADDING * 2;

	var shape = new createjs.Shape();
	var g = shape.graphics;
	var r = GH / 2;
	// !勘所! 引数の数の違いに注意
	// ASでは透明度を指定する引数が別に用意されているが、CreateJSではその引数が削除されcolor配列に取り込まれている
	g.beginRadialGradientFill(
		[createjs.Graphics.getRGB(0x00, 0x00, 0x00, 1), createjs.Graphics.getRGB(0xFF, 0xFF, 0xFF, 0)],
		[0, 1], 2 * r, 2 * r, 0, -r, -r, r);
	g.drawCircle(0, 0, r);
	g.endFill();

	shape.x = (W - GW) / 2 + r;
	shape.y = (H - GH) / 2 + r;

	return shape;
}

/**
* @return {Shape} すみません、何に使うShapeオブジェクトなのかしっかり理解していないです.
* おそらく中心に近いほど光が強くなる作用を実現するためのオブジェクトだと思います
*/
function createOverlayRect()
{
	var SCALE_W = 2;
	var SCALE_H = 1.2;
	var RW = SCALE_W * W / 2;
	var RH = SCALE_H * H / 2;

	var shape = new createjs.Shape();
	var g = shape.graphics;
	g.beginRadialGradientFill(
		[createjs.Graphics.getRGB(0xFF, 0xFF, 0xFF, 1), createjs.Graphics.getRGB(0, 0, 0, 1)],
		[0, 1], 2 * RW, 2 * RH, 0, 0, 0, Math.max(RW * 2, RH * 2));
	g.drawEllipse(0, 0, RW * 2, RH * 2);
	g.endFill();

	// !勘所! 現在のShapeにはboundsを自動的に計算しないのでsetBoundsをしないといけない
	// 今回は適当な値を入れて対処した
	var shapeWidth = RW * 2;
	var shapeHeight = RH * 2;
	shape.x = (W - shapeWidth) / 2;
	shape.y = (H - shapeHeight) / 2;
	//shape.blendMode = BlendMode.OVERLAY;
	// canvasではcompositeOperationにBlendMode.OVERRAYと同様の動作をするものがなかったので諦めた

	//shape.cacheAsBitmap = true;
	// CreateJSでは自動的にキャッシュするわけではないので明示的にcacheする。
	// そうすることで、裏でshapeをcanvasとしてcacheすることが出来る
	shape.cache(-shapeWidth / 2, -shapeHeight / 2, shapeWidth / 2, shapeHeight / 2);

	return shape;
}

/**
* @return {Shape} すみません、何に使うShapeオブジェクトなのかしっかり理解していないです.
* おそらく光線が文字から離れるほど薄くする作用を作っている
*/
function createTrimRect()
{
	var TY = 35;

	var shape = new createjs.Shape();
	var g = shape.graphics;
	var mat = new createjs.Matrix2D();
	g.beginLinearGradientFill(
		[createjs.Graphics.getRGB(0, 0, 0, 1), createjs.Graphics.getRGB(0, 0, 0, 0), createjs.Graphics.getRGB(0, 0, 0, 0), createjs.Graphics.getRGB(0, 0, 0, 1)],
		[0, 51/255, 204/255, 255/255], W, H - TY * 2, 0, TY);
	g.drawCircle(0, 0, W, H);
	g.endFill();
	mat.rotate(Math.PI / 2);
	mat.decompose(shape);
	//shape.cacheAsBitmap = true;
	shape.cache(-W, -H, W, H);

	return shape;
}