アニメで見て面白いなと思ったエフェクトをwebglで実装してみた話。
今回の成果はGAEでアップロードしてあります。
https://mosaic-camera.appspot.com/musaigen
ソースコードをgithubにあげました!
https://github.com/aratakokubun/musaigen/tree/master
きっかけ
最近会社の同期と簡単なモノを作って発表するハッカソン的なイベントをやっていて、なんかないかなーと思ってアニメを見ているとちょうどいいネタが。
簡単には、シルエットを文字画像で置き換えるエフェクトっぽいです。
毎週見てる人には分かると思いますが、某アニメ(タイトル出してますが)のCM前後で流れる画像です。これの名前をググってみたらアイキャッチというらしいですね・・・全然知らなかった・・・
とりあえずwebgl+video(html5)でこのエフェクトをリアルタイムなカメラ画像にかけられたら面白そうだなと思って実装してみました。
webgl+videoを使ったのは簡単にクロスプラットフォームで動かせると思ったからです。
実装の全体観
エフェクトとしては、モザイクの1セルを文字の画像で置き換え、その文字を流動的に変化させるものになります。
やろうとしていることは、大まかに以下になります。
- カメラ画像を取得し、テクスチャ化
- テクスチャを分割し、領域ごとの画素値の平均を算出
- 画素値が閾値の範囲内の場合は文字画像を描画
今回の記事では自分が経験した前段階として、
0. webglでテクスチャ描画
から説明していきます。
0.webglでテクスチャ描画
手軽に3D描画が可能なJSライブラリのThree.jsを使いました。
使用したサンプルは以下で、shaderを使って描画をします。
要点だけかいつまんで説明します。
http://stemkoski.github.io/Three.js/Textures.html
(shader何って人はまずggってからこの先をみてください。基本的にはGPUを使用して画像処理を高速化するものと考えてくれれば良いと思います。ただ、コードを書く際はそれを意識しなくてもできる作りになっています。)
描画シーンの作成
Three.jsでは、sceneにカメラ、ライト、テクスチャ、Shader等の要素を追加していくことで描画をしていきます。
重要なのはカメラとライトを加えることです。これがないとテクスチャを追加しても表示がされません。
カメラは角度、アスペクト比、NearClip・FarClipに注意して設定をしてください。
点光源(spotlight)や環境光(ambient)などがあるので、場合に応じて設定してください。
// シーンを初期化
scene = new THREE.Scene();
var SCREEN_WIDTH = window.innerWidth, SCREEN_HEIGHT = window.innerHeight;
var VIEW_ANGLE = 45, ASPECT = SCREEN_WIDTH / SCREEN_HEIGHT, NEAR = 0.1, FAR = 20000;
camera = new THREE.PerspectiveCamera( VIEW_ANGLE, ASPECT, NEAR, FAR);
// シーンにカメラを追加し、位置を調整
scene.add(camera);
camera.position.set(0,0,400); camera.lookAt(scene.position);
// ライトをシーンに追加し、位置を調整
var light = new THREE.PointLight(0xffffff);
light.position.set(0,150,100);
scene.add(light);
Shaderの追加
今回も使用しているファイルからTexture生成 -> 描画用Shader生成の流れです。
まず、画像ファイルなどからTexture生成し、Shaderに追加する準備をします。
var showImgSrc = "image/texture.png";
var imageTexture = THREE.ImageUtils.loadTexture(showImgSrc);
imageTexture.magFilter = THREE.NearestFilter;
imageTexture.minFilter = THREE.NearestFilter;
作成したテクスチャをShaderに渡し、sceneに追加します。
uniform変数はshaderの処理で使用する値を渡します。(参考文献 2)
また、shaderソースの読み込みにはShaderLoaderを使用してファイルを分割しました。(参考文献 3)
var imageWidth = newImg.width;
var imageHeight = newImg.height;
// テクスチャサイズ、変形時の分割数(vertex)を設定したGeometryを作成
var textureGeometry = new THREE.PlaneGeometry(imageWidth, imageHeight, 1, 1);
// Shaderに渡すuniform変数を宣言
myShaderUniforms = {
texture: { type: 't', value: imageTexture},
};
// shader、uniform変数を指定したMaterialを宣言
// vertex, fragmentshaderはShaderLoaderを使用して別ファイルから読み込んだソースを使用
var shaderMaterial = new THREE.ShaderMaterial({
vertexShader: vertex,
fragmentShader: fragment,
uniforms: myShaderUniforms
});
// GeometryとMaterialからMesh(シェーディングまで含んだ3Dオブジェクト)の作成し、sceneに追加
scene.add(new THREE.Mesh(textureGeometry, shaderMaterial));
ここから先はshader側のソースになります。
とりあえず読み込んだテクスチャを描画するだけのシンプルなソースです。
varying変数はvertexからfragmentへの変数の橋渡しとして利用します。
precision mediump float;
varying vec2 vUv;
void main()
{
vUv = uv;
// カメラ外部パラメータ(modelViewMatrix)
// カメラ内部パラメータ(projectionMatrix)
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
precision mediump float;
uniform sampler2D texture;
varying vec2 vUv;
void main()
{
// テクスチャの指定した箇所の画素を描画
gl_FragColor = texture2D(texture, vUv);
}
1. カメラ画像を取得し、テクスチャ化
html5でのvideoでの読み込みは参考文献4に記載しています。
これをThree.jsのVideoTextureでテクスチャ化しています。(参考文献 5)
また、loadedmetadataで動画サイズの読み込みが終わった際にサイズを取得しています。(参考文献 6)
window.addEventListener("DOMContentLoaded", function() {
// Grab elements, create settings, etc.
var videoObj = { "video": true },
errBack = function(error) {
console.log("Video capture error: ", error.code);
};
// Put video listeners into place
if(navigator.getUserMedia) { // Standard
navigator.getUserMedia(videoObj, function(stream) {
video.src = stream;
video.play();
videoTexture = new THREE.VideoTexture( video );
videoTexture.magFilter = THREE.NearestFilter;
videoTexture.minFilter = THREE.NearestFilter;
}, errBack);
} else if(navigator.webkitGetUserMedia) { // WebKit-prefixed
navigator.webkitGetUserMedia(videoObj, function(stream){
video.src = window.webkitURL.createObjectURL(stream);
video.play();
videoTexture = new THREE.VideoTexture( video );
videoTexture.magFilter = THREE.NearestFilter;
videoTexture.minFilter = THREE.NearestFilter;
}, errBack);
}
else if(navigator.mozGetUserMedia) { // Firefox-prefixed
navigator.mozGetUserMedia(videoObj, function(stream){
video.src = window.URL.createObjectURL(stream);
video.play();
videoTexture = new THREE.VideoTexture( video );
videoTexture.magFilter = THREE.NearestFilter;
videoTexture.minFilter = THREE.NearestFilter;
}, errBack);
}
else {
console.log("failed to get media");
return;
}
video.addEventListener( "loadedmetadata", function (e) {
videoWidth = this.videoWidth,
videoHeight = this.videoHeight;
// Reload shader on reload completely
updateShader();
}, false );
}, false);
2. テクスチャを分割し、領域ごとの画素値の平均を算出
この部分はfragmentshaderでサクッと処理をします。
単純に分割して領域の端点、サイズからその領域に含まれる画素値の合計を計算。その後含まれる画素数で割れば平均値になります。
下のコードではmag > divとなるように拡大処理をかけていますが、はっきりとした理由はわかっていませんが、mod処理で0になることを回避するために行っています。
回避策が分かる方は教えて頂きたいです。
precision mediump float;
uniform sampler2D texture;
varying vec2 vUv;
// 画像拡大率
const vec2 mag = vec2(512.0, 512.0);
// 分割数
const vec2 div = vec2(32.0, 32.0);
const vec2 divRange = vec2(mag.x/div.x, mag.y/div.y);
const float nFrag = 1.0 / (divRange.x*divRange.y);
vec4 applyEffect(vec2 uvCoord) {
vec4 aveColor = vec4(0.0);
// mod関数で分割するため、最初に拡大しておく
vec2 magUv = uvCoord * mag;
vec2 offset = vec2(mod(magUv.x, divRange.x), mod(magUv.y, divRange.y));
// 分割範囲内の画像の平均値を算
for(float x = 0.0; x <= divRange.x; x += 1.0){
for(float y = 0.0; y <= divRange.y; y += 1.0){
aveColor += texture2D(texture, (magUv + vec2(x - offset.x, y - offset.y)) / mag);
}
}
aveColor /= divRange.x*divRange.y;
return aveColor;
}
void main()
{
gl_FragColor = applyEffect(vUv);
}
3. 画素値が閾値の範囲内の場合は文字画像を描画
前の部分で分割した画素値を算出できたので、後は閾値内かどうかの計算と、文字画像の描画になります。
閾値の判定
画像処理でよく使われるhsv色空間での閾値判定を行います。
このために、まずrgb -> hsvの変換をかけます。(参考文献 7)
vec3 rgb2hsv(vec3 c)
{
vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
vec4 p = mix(vec4(c.bg, K.wz), vec4(c.gb, K.xy), step(c.b, c.g));
vec4 q = mix(vec4(p.xyw, c.r), vec4(c.r, p.yzx), step(p.x, c.r));
float d = q.x - min(q.w, q.y);
float e = 1.0e-10;
return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);
}
後はuniform等で渡したhsv値の上限下限の閾値と#2で算出した画素の平均値を比較して、含まれている画素は次の処理に移します。
文字画像の描画
地味ですが、ここが一番苦労したところです。
描画の際は文字が1枚のファイルに集まった画像をテクスチャとして渡し、どの文字を描画するかを領域ごとに端点とサイズによって分けています。
まず、領域が今回は6464で分けているので、各セルのアルファベットを指定するために同じサイズの2次元ないし、1次元の配列が必要になります。
しかし、webglのuniformで渡せる配列の大きさには限界があり、6464は配列としては渡せませんでした。
回避策としては、以下の2パターンがあります。
- テクスチャとして配列を渡し、指定箇所の画素値から値を抽出する。(参考文献 8)
- 頂点情報としてattributeに渡し、vertexからvaryingで値を渡す。
今回は1の方法の方が楽そうなので、テクスチャ化によって実装しました。
テクスチャ画像からどの部分を描画すれば指定した文字が描画されるかを配列で宣言し、テクスチャを以下のように作成しました。
alphabetOffsetArrayが各文字の描画位置を格納した配列、
alphaMatrixが64*64の各要素がどのアルファベットを描画するかindexを格納した配列になります。
また、rgbaの各値には文字の開始位置(x,y)とサイズ(w,h)を格納しています。
// Set (x,y,w,h) -> (r, g, b, a)
for (var i=0; i < div.x*div.y; i++) {
// matrix index -> coords(x,y,w,h)
var offset = alphabetOffsetArray[alphaMatrix[i]];
alphaUint8Matrix[i*4 ] = Math.floor(offset.x * 255);
alphaUint8Matrix[i*4+1] = Math.floor(offset.y * 255);
alphaUint8Matrix[i*4+2] = Math.floor(alphaSize.x * 255);
alphaUint8Matrix[i*4+3] = Math.floor(alphaSize.y * 255); }
alphaMatrixTexture = new THREE.DataTexture(alphaUint8Matrix, div.x, div.y, THREE.RGBAFormat);
fragmentshaderでは64*64分割の各領域に対応する位置の画素を取得し、文字の描画位置を指定します。その位置の中でさらに現在の画素位置に対応する文字画像の中の画素位置を指定して描画をしています。
vec2 indices = vec2(floor(magUv.x/divRange.x), floor(magUv.y/divRange.y));
vec2 alphaIndexMatCoords = vec2(float(indices.x)/div.x, float(indices.y)/div.y);
vec4 alphabetOffset = texture2D(alphaMatrixTexture, alphaIndexMatCoords);
vec2 alphabetCoords = vec2(alphabetOffset.x + alphabetOffset.z*posInAlphabet.x,
alphabetOffset.y + alphabetOffset.w*posInAlphabet.y);
gl_FragColor = texture2D(alphabets, alphabetCoords);
結果
結果がこちらになります。
左上から元画像, モザイク, 色抽出, 文字画像で置換の結果になります。(閾値設定適当ですが・・・)
また、右下が分割数が多いですが、文字画像がびっしりと貼られています。
ブラウザで動くデモが以下になります。
Chromeでカメラアクセスを許可しての動作は確認済みです。
GUIは色々いじってみてください。
https://mosaic-camera.appspot.com/musaigen
githubのソース
https://github.com/aratakokubun/musaigen/tree/master
感想
お作法を知らないと結構難しいですが、慣れればかなりサクッと作れるのでwebglはいいなと思いました。
ここでは書いていませんが、文字描画の部分は試行錯誤を重ねており、配列の要素やループ回数を変数で指定できないなど難点はかなりありました。ただ、shaderの特徴を考えるとそのような処理をすること自体が非効率であり、shaderでやらせること、それ以外でやらせることが分かっていればそれほど難しいことではないと思います。
余談ですが、本家と文字の色結構違ったのは考えていませんでした。自分のフィーリングに合う画像を探すのに30分かけたのに肝心なところができていませんでした・・・
参考文献
- webglでのテクスチャ描画
http://izmiz.hateblo.jp/entry/2014/09/27/235743 - uniform変数の渡し方
https://github.com/mrdoob/three.js/wiki/Uniforms-types - ShaderLoader
https://github.com/codecruzer/webgl-shader-loader-js - html5でカメラ画像をvideo要素に取り込む
http://www.html5rocks.com/ja/tutorials/getusermedia/intro/ - three.jsで動画をテクスチャに指定
http://qiita.com/edo_m18/items/b697cba36de168e8a608 - htmlのvideoタグで再生される動画のサイズを取得
http://stackoverflow.com/questions/4129102/html5-video-dimensions - Shaderでのrgb->hsv式空間の変換
http://lolengine.net/blog/2013/07/27/rgb-to-hsv-in-glsl - Three.jsでデータ配列からテクスチャを生成
http://miffysora.wikidot.com/threejs-data-texture