簡易Live2DViewer作ってみた

Live2D SDK WebGL版で簡易Live2DViewer作ってみました。
Live2Dモデルのファイルをサーバーにアップロードさせたくなかったので、全てHTML5の機能で実装してます。
ファイル選択とファイルのドラッグ＆ドロップに対応しています
（テクスチャは複数選択してドラッグ＆ドロップして下さい）

・[簡易Live2DViewer](http://simplecode.jp/Live2D_DragDrop/)
![schreenshot.png](https://qiita-image-store.s3.amazonaws.com/0/15028/b9f2c140-66a4-eae4-9d48-a39c5054c401.png)

# フォルダ構成
Live2D SDKのSimpleプロジェクトを元にカスタムしています。

```ini
Live2D_DragDrop
│
│  index.html
│
├─framework
│    Live2DFramework.js・・・SDK付属のフレームワーク
│    
├─lib
│    live2d.min.js・・・Live2Dのコアライブラリ
│    
└─src
      Simple.js
```

# ソース
既存のファイル選択ボタンだとダサいので、ダミーボタンを表示してファイル選択処理を呼んでいます

```html:index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8">
            <title>Live2D Simple</title>
        </meta>
        <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=4.0">
        </meta>
    <style>
        /* 選択したファイルのサムネイル表示 */
      .thumb {
        height: 75px;
        border: 1px solid #000;
        margin: 10px 5px 0 0;
      }
      .drop_area{
          border:dashed 2px #ccc;
          width:512px;
          height:80px;
          margin:4px;
          background-color:#efefef;
      }
      .moc_area, .texture_area, .motion_area{
          padding-top:10px;
          font-size: 18px;
          color:blue;
          text-align: center;
      }
      #select_moc, #select_texture, #select_motion{
          display:none;
      }
      #dummy_moc_btn, #dummy_texture_btn, #dummy_motion_btn{
          margin-top:10px;
          margin-left:150px;
          font-size: 12px;
          text-decoration: none;
          color:#fff;
          background-color: #49a9d4;
          border:none;
          border-radius: 5px;
          padding:2px 16px 2px 16px;
      }
      #dummy_moc_file, #dummy_motion_file{
          width:200px;
          height: 20px;
          font-size: 14px;
          border:none;
          background-color: #e6e6e6;
      }
      #displaybtn{
          width: 160px;
          height:40px;
          margin-left:4px;
          margin-bottom:4px;
          font-size:20px;
          font-weight: bold;
          color:#fff;
          background-color: #49a9d4;
          border:none;
          border-radius: 5px;
          padding:2px 16px 2px 16px;
          box-shadow:2px 2px #1a6ea0;
          text-shadow:1px 1px #555;
      }
      #displaybtn:hover{
          color:#fff;
          background-color: #0F7DAE;
          box-shadow: 2px 2px #073E5F;
      }
      .live2d_area{
          position: absolute;
          top:4px;
          left:528px;
      }
      #glcanvas{
          border:dashed 2px #CCC;
          margin:4px;
      }
    </style>
    </head>
    <body>
        <!-- mocファイル -->
        <div class="drop_area" id="drop_moc">
            <div class="moc_area">mocをドロップ</div>        
            <input type="file" id="select_moc" name="files" />
            <button id="dummy_moc_btn" onclick="dummy_moc_click()">mocファイル選択</button>
            <input type="text" id="dummy_moc_file" readonly>
        </div>
        <!-- textureファイル -->
        <div class="drop_area" id="drop_texture">
            <div class="texture_area">texutureをドロップ</div>        
            <input type="file" id="select_texture" name="files[]" multiple />            
            <button id="dummy_texture_btn" onclick="dummy_texture_click()">textureファイル選択</button>
        </div>
        <!-- 選択したしたテクスチャを表示する部分 -->
        <output id="texture_list"></output>
        <br>
        <!-- motionファイル -->
        <div class="drop_area" id="drop_motion">
            <div class="motion_area">motionをドロップ</div>        
            <input type="file" id="select_motion" name="files" />
            <button id="dummy_motion_btn" onclick="dummy_motion_click()">motionファイル選択</button>
            <input type="text" id="dummy_motion_file" readonly>
        </div>
        <!-- モデル表示ボタン -->
        <button id="displaybtn" onclick="Simple()">モデル表示</button>
        <div class="live2d_area">
            <!-- Live2Dモデル -->
            <canvas id="glcanvas" width = "512px" height="512px"></canvas>
            <div id="myconsole" style="color:#BBB">---- Log ----</div>                    
        </div>
        <!-- Live2D Library -->
        <script src="lib/live2d.min.js"></script>        
        <script src="framework/Live2DFramework.js"></script>
        <!-- User's Script -->
        <script src="src/Simple.js"></script>
    </body>
</html>
```

元々はLive2Dモデルファイル(.moc、.mtn）はXMLHttpRequestのarraybuffer形式でロードしてました。
今回はファイル選択形式なので、FileReaderでBase64形式にしてロードしてます。
Base64は小さい画像のみ有効かと思ったら、1024px x 1024pxのテクスチャ5枚とかもさくっとロードしてくれました。

```js:Simple.js
/******************** mocファイル ********************/
var binarymoc = null;
// mocファイルのファイル選択ボタン（非表示でダミーボタンで装飾）
var select_moc = document.getElementById('select_moc');
select_moc.addEventListener('change', mocSelect, false);
// mocファイル選択名
var dummy_moc_file = document.getElementById('dummy_moc_file');
// mocファイルのドロップ領域
var drop_moc = document.getElementById('drop_moc');
drop_moc.addEventListener('drop', mocDrop);
drop_moc.addEventListener('dragover', mocDragOver);
drop_moc.addEventListener('dragleave', mocDragLeave);

// dummy用のmocファイル選択ボタンからイベント呼び出す
function dummy_moc_click(){
    select_moc.click();
}
// mocファイルドラッグオーバー時
function mocDragOver(event){
    drop_moc.style.backgroundColor = "#C1C1C1"; // hover色
    event.preventDefault();
}
// mocファイルドラッグリーブ時
function mocDragLeave(event){
    drop_moc.style.backgroundColor = "#efefef"; // 元の色
    event.preventDefault();    
}
// mocファイルドロップ時
function mocDrop(evt){
    // ドロップしたファイル情報
    var files = evt.dataTransfer.files;
    moc_load(files);
    evt.preventDefault();
    drop_moc.style.backgroundColor = "#efefef"; // 元の色
}
// mocファイル選択時
function mocSelect(evt) {
    // 選択したファイル情報
    var files = evt.target.files;
    moc_load(files);
}
// mocファイルロード処理
function moc_load(files){
    // 初期化処理
    binarymoc = null;
    dummy_moc_file.value = "";
    for (var i = 0, f; f = files[i]; i++) {
        var reader = new FileReader();
        // 選択したファイル名をセット
        dummy_moc_file.value = files[i].name;
        reader.onload = (function(theFile) {
            return function(e) {
                // moc中身をセット(Base64形式)
                binarymoc = e.target.result;
            };
        })(f);
        // arrayBuffer形式
        reader.readAsArrayBuffer(f);
    }    
}

/******************** textureファイル ********************/
var binarytextures = [];
// textureファイルの選択ボタン（非表示でダミーボタンで装飾）
var select_texture = document.getElementById('select_texture');
select_texture.addEventListener('change', TextureSelect, false);
// textureファイルのドロップ領域
var drop_texture = document.getElementById('drop_texture');
drop_texture.addEventListener('drop', textureDrop);
drop_texture.addEventListener('dragover', textureDragOver);
drop_texture.addEventListener('dragleave', textureDragLeave);

// textureファイル選択ボタンからイベント呼び出す
function dummy_texture_click(){
    select_texture.click();
}
// textureファイルドラッグオーバー時
function textureDragOver(event){
    drop_texture.style.backgroundColor = "#C1C1C1"; // hover色
    event.preventDefault();
}
// textureファイルドラッグリーブ時
function textureDragLeave(event){
    drop_texture.style.backgroundColor = "#efefef"; // 元の色
    event.preventDefault();    
}
// textureファイルドロップ時
function textureDrop(evt){
    // ドロップしたファイル情報
    var files = evt.dataTransfer.files;
    texture_load(files);
    evt.preventDefault();
    drop_texture.style.backgroundColor = "#efefef"; // 元の色
}
// textureファイル選択時
function TextureSelect(evt) {
    // 選択したファイル情報
    var files = evt.target.files;
    texture_load(files);
}

// textureファイルロード処理
function texture_load(files){
    // 配列をクリアしておく
    binarytextures = [];
    // 前回のサムネイルを削除する
    var output = document.getElementById('texture_list');
    while(output.firstChild){
        output.removeChild(output.firstChild);
    }

    for (var i = 0, f; f = files[i]; i++) {
        var reader = new FileReader();
        // 非同期処理のため、ファイル番号を保持しておく
        reader.filenm = i;
        reader.onload = (function(theFile) {
            return function(e) {
                // ロードしたテクスチャのサムネイル表示する
                var span = document.createElement('span');
                span.innerHTML = ['<img class="thumb" src="', e.target.result,
                                    '" title="', escape(theFile.name), '"/>'].join('');
                document.getElementById('texture_list').insertBefore(span, null);
                // 変数にBase64エンコード形式で保持
                binarytextures[e.target.filenm] = e.target.result;
            };
        })(f);
        // 画像はこちらをつかう
        reader.readAsDataURL(f);
    }    
}

/******************** motionファイル ********************/
var binarymotion = null;
// motionファイルのファイル選択ボタン（非表示でダミーボタンで装飾）
var select_motion = document.getElementById('select_motion');
select_motion.addEventListener('change', motionSelect, false);
// motionファイル選択名
var dummy_motion_file = document.getElementById('dummy_motion_file');
// motionファイルのドロップ領域
var drop_motion = document.getElementById('drop_motion');
drop_motion.addEventListener('drop', motionDrop);
drop_motion.addEventListener('dragover', motionDragOver);
drop_motion.addEventListener('dragleave', motionDragLeave);

// dummy用のmotionファイル選択ボタンからイベント呼び出す
function dummy_motion_click(){
    select_motion.click();
}
// motionファイルドラッグオーバー時
function motionDragOver(event){
    drop_motion.style.backgroundColor = "#C1C1C1"; // hover色
    event.preventDefault();
}
// motionファイルドラッグリーブ時
function motionDragLeave(event){
    drop_motion.style.backgroundColor = "#efefef"; // 元の色
    event.preventDefault();    
}
// motionファイルドロップ時
function motionDrop(evt){
    // ドロップしたファイル情報
    var files = evt.dataTransfer.files;
    motion_load(files);
    evt.preventDefault();
    drop_motion.style.backgroundColor = "#efefef"; // 元の色
}
// motionファイル選択時
function motionSelect(evt) {
    // 選択したファイル情報
    var files = evt.target.files;
    motion_load(files);
}
// motionファイルロード処理
function motion_load(files){
    // 初期化処理
    binarymotion = null;
    dummy_motion_file.value = "";
    for (var i = 0, f; f = files[i]; i++) {
        var reader = new FileReader();
        // 選択したファイル名をセット
        dummy_motion_file.value = files[i].name;
        reader.onload = (function(theFile) {
            return function(e) {
                // motion中身をセット(Base64形式)
                binarymotion = e.target.result;
                Simple();
            };
        })(f);
        // arrayBuffer形式
        reader.readAsArrayBuffer(f);
    }    
}


// JavaScriptで発生したエラーを取得
window.onerror = function(msg, url, line, col, error) {
    var errmsg = "file:" + url + "<br>line:" + line + " " + msg;
    Simple.myerror(errmsg);
}

var Simple = function() {

    /*
    * Live2Dモデルのインスタンス
    */
    this.live2DModel = null;
    
    /*
    * アニメーションを停止するためのID
    */
    this.requestID = null;
    
    /*
    * モデルのロードが完了したら true
    */
    this.loadLive2DCompleted = false;
    
    /*
    * モデルの初期化が完了したら true
    */
    this.initLive2DCompleted = false;
    
    /*
    * WebGL Image型オブジェクトの配列
    */
    this.loadedImages = [];
    /*
    * Live2D モデル設定。
    */
//    this.modelDef = {
//        
//        "type":"Live2D Model Setting",
//        "name":"haru",
//        "model":"assets/haru/haru.moc",
//        "textures":[
//            "assets/haru/haru.1024/texture_00.png",
//            "assets/haru/haru.1024/texture_01.png",
//            "assets/haru/haru.1024/texture_02.png"
//        ]
//    };
    this.motion = null;     // モーション
    this.motionMgr = null;  // モーションマネジャー
    
    // Live2Dの初期化
    Live2D.init();
    

    // canvasオブジェクトを取得
	var canvas = document.getElementById("glcanvas");

	// コンテキストを失ったとき
    
	canvas.addEventListener("webglcontextlost", function(e) {
        Simple.myerror("context lost");
        loadLive2DCompleted = false;
        initLive2DCompleted = false;
        
        var cancelAnimationFrame = 
            window.cancelAnimationFrame || 
            window.mozCancelAnimationFrame;
        cancelAnimationFrame(requestID); //アニメーションを停止
        
        e.preventDefault(); 
    }, false);
    
    // コンテキストが復元されたとき
	canvas.addEventListener("webglcontextrestored" , function(e){
        Simple.myerror("webglcontext restored");
        Simple.initLoop(canvas); 
    }, false);

	// Init and start Loop
	Simple.initLoop(canvas);
};


/*
* WebGLコンテキストを取得・初期化。
* Live2Dの初期化、描画ループを開始。
*/
Simple.initLoop = function(canvas/*HTML5 canvasオブジェクト*/) 
{
    //------------ WebGLの初期化 ------------
    
	// WebGLのコンテキストを取得する
    var para = {
        premultipliedAlpha : true,
//        alpha : false
    };
	var gl = Simple.getWebGLContext(canvas, para);
	if (!gl) {
        Simple.myerror("Failed to create WebGL context.");
        return;
    }

	// 描画エリアを白でクリア
	gl.clearColor( 0.0 , 0.0 , 0.0 , 0.0 );	 

    //------------ Live2Dの初期化 ------------
    
	// mocファイルからLive2Dモデルのインスタンスを生成
//	Simple.loadBytes(modelDef.model, function(buf){
//		live2DModel = Live2DModelWebGL.loadModel(buf);
//	});
        live2DModel = Live2DModelWebGL.loadModel(binarymoc);

	// テクスチャの読み込み
    var loadCount = 0;
//	for(var i = 0; i < modelDef.textures.length; i++){
	for(var i = 0; i < binarytextures.length; i++){
		(function ( tno ){// 即時関数で i の値を tno に固定する（onerror用)
			loadedImages[tno] = new Image();
//			loadedImages[tno].src = modelDef.textures[tno];
			loadedImages[tno].src = binarytextures[i];
			loadedImages[tno].onload = function(){
//				if((++loadCount) == modelDef.textures.length) {
				if((++loadCount) == binarytextures.length) {
                    loadLive2DCompleted = true;//全て読み終わった
                }
			}
//			loadedImages[tno].onerror = function() { 
//				Simple.myerror("Failed to load image : " + modelDef.textures[tno]); 
//			}
		})( i );
	}
        // モーションのロード
        motion = new Live2DMotion.loadMotion(binarymotion);
        // モーションマネージャのインスタンス化
        motionMgr = new L2DMotionManager();
        
	//------------ 描画ループ ------------
    
    (function tick() {
        Simple.draw(gl); // 1回分描画
        
        var requestAnimationFrame = 
            window.requestAnimationFrame || 
            window.mozRequestAnimationFrame ||
            window.webkitRequestAnimationFrame || 
            window.msRequestAnimationFrame;
		requestID = requestAnimationFrame( tick , canvas );// 一定時間後に自身を呼び出す
    })();
};


Simple.draw = function(gl/*WebGLコンテキスト*/)
{
    // Canvasをクリアする
    gl.clear(gl.COLOR_BUFFER_BIT);

    // Live2D初期化
    if( ! live2DModel || ! loadLive2DCompleted ) 
    return; //ロードが完了していないので何もしないで返る
	
    // ロード完了後に初回のみ初期化する
    if( ! initLive2DCompleted ){
            initLive2DCompleted = true;

        // 画像からWebGLテクスチャを生成し、モデルに登録
        for( var i = 0; i < loadedImages.length; i++ ){
            //Image型オブジェクトからテクスチャを生成
            var texName = Simple.createTexture(gl, loadedImages[i]);

            live2DModel.setTexture(i, texName); //モデルにテクスチャをセット
        }

        // テクスチャの元画像の参照をクリア
        loadedImages = null;

        // OpenGLのコンテキストをセット
        live2DModel.setGL(gl);

        // 表示位置を指定するための行列を定義する
        var s = 2.0 / live2DModel.getCanvasWidth(); //canvasの横幅を-1..1区間に収める
        var matrix4x4 = [ s,0,0,0 , 0,-s,0,0 , 0,0,1,0 , -1.0,1,0,1 ];
        live2DModel.setMatrix(matrix4x4);
    }
    
    // キャラクターのパラメータを適当に更新
//    var t = UtSystem.getTimeMSec() * 0.001 * 2 * Math.PI; //1秒ごとに2π(1周期)増える
//    var cycle = 3.0; //パラメータが一周する時間(秒)
    // PARAM_ANGLE_Xのパラメータが[cycle]秒ごとに-30から30まで変化する
//    live2DModel.setParamFloat("PARAM_ANGLE_X", 30 * Math.sin(t/cycle));
    // モーションが終了していたらモーションの再生
    if(motionMgr.isFinished()){
        motionMgr.startMotion(motion);
    }
    motionMgr.updateParam(live2DModel);

    // Live2Dモデルを更新して描画
    live2DModel.update(); // 現在のパラメータに合わせて頂点等を計算
    live2DModel.draw();	// 描画
};


/*
* WebGLのコンテキストを取得する
*/
Simple.getWebGLContext = function(canvas/*HTML5 canvasオブジェクト*/)
{
	var NAMES = [ "webgl" , "experimental-webgl" , "webkit-3d" , "moz-webgl"];
	
    var param = {
        alpha : true,
        premultipliedAlpha : true
    };
    
	for( var i = 0; i < NAMES.length; i++ ){
		try{
			var ctx = canvas.getContext( NAMES[i], param );
			if( ctx ) return ctx;
		} 
		catch(e){}
	}
	return null;
};


/*
* Image型オブジェクトからテクスチャを生成
*/
Simple.createTexture = function(gl/*WebGLコンテキスト*/, image/*WebGL Image*/) 
{
    var texture = gl.createTexture(); //テクスチャオブジェクトを作成する
    if ( !texture ){
        mylog("Failed to generate gl texture name.");
        return -1;
    }
    
    gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, 1);
    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);	//imageを上下反転
    gl.activeTexture( gl.TEXTURE0 );
    gl.bindTexture( gl.TEXTURE_2D , texture );
    gl.texImage2D( gl.TEXTURE_2D , 0 , gl.RGBA , gl.RGBA , gl.UNSIGNED_BYTE , image);    
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_NEAREST);        
    gl.generateMipmap(gl.TEXTURE_2D);
    gl.bindTexture( gl.TEXTURE_2D , null );
    
    return texture;
};


/*
* ファイルをバイト配列としてロードする
*/
Simple.loadBytes = function(path , callback)
{
	var request = new XMLHttpRequest();
	request.open("GET", path , true);
	request.responseType = "arraybuffer";
	request.onload = function(){
		switch( request.status ){
		case 200:
			callback( request.response );
			break;
		default:
			Simple.myerror( "Failed to load (" + request.status + ") : " + path );
			break;
		}
	}
    
    request.send(null); 
};


/*
* 画面ログを出力
*/
Simple.mylog = function(msg/*string*/)
{
	var myconsole = document.getElementById("myconsole");
	myconsole.innerHTML = myconsole.innerHTML + "<br>" + msg;
	console.log(msg);
};

/*
* 画面エラーを出力
*/
Simple.myerror = function(msg/*string*/)
{
    console.error(msg);
	Simple.mylog( "<span style='color:red'>" + msg + "</span>");
};
```

# 課題
テクスチャのロードは、0番から正しく順番に読み込まれないと表示が崩れます。

あと、Live2DViewerだとmocやmtnファイルパスを書いたmodel.json1つロードするだけでモデルが表示できます。
HTML5だとセキュリティ上、ファイルを直接選択する必要があるのでmodel.jsonからロードはできない…。
（結局はphpでファイルアップロードさせる方がいいかも）

ただ、Live2DとWebGLで作ったポストエフェクトなどはユーザが作ったモデルで試せるサイトが作れるので、何か面白いもの作ったらこれで公開していこうと思います。