SVG+CSS AnimationでLive2Dを動かす

f:id:spring_raining:20200125150243p:plain

年末年始の自由研究として、CSSだけでLive2Dを動かすことができないかを試していました。紆余曲折あったものの、なんとか動きそうということが分かったのでひとまず情報共有。

Live2Dとは

Live2Dは、2Dのイラストをモーフィング技術を使ってアニメーションさせるソフトウェアです。あのアプリゲームのキャラクターや、あのVTuberを動かすために使われています。

f:id:spring_raining:20200125170034g:plain:w300

かわいい!!!

モデルデータを表示させる

Source: CSSLive2D/src/01_parse at master · spring-raining/CSSLive2D · GitHub

まず、Live2D公式サイトにあるサンプルデータのキャラクターを画面に表示させることを目標としてみます。各キャラクターはそれぞれモデルデータ (Haru.moc3)、テクスチャ画像 (*.png)、表情データ (*.exp3.json)、モーションデータ (*.motion3.json) などが用意されているようです。

f:id:spring_raining:20200125171758p:plain

モデルデータはバイナリかつ仕様が非公開になっていますが…

f:id:spring_raining:20200125172813p:plain

公式のLive2D Cubism Core SDKを使うとある程度の情報を得ることができます。

f:id:spring_raining:20200125172629p:plain

この情報を読み取ってみると、各パーツの形状に関する情報は drawables に含まれていることが分かってきます。

Live2Dのモデルはパーツの集合で出来ていて、1つのパーツにはメッシュと呼ばれる三角形の集合体が割り当てられています。メッシュの頂点はその位置のテクスチャと紐付いており、頂点を動かすことでキャラクターをアニメーションさせているようです。

f:id:spring_raining:20200125173520p:plain

それぞれのパーツに1枚1枚独立したテクスチャ画像は用意されておらず、複数のテクスチャがUVマップのような形でまとめられています (このモデルの場合2048x2048が2枚)。

f:id:spring_raining:20200125180547p:plain

そして、この頂点の位置情報と、各座標とテクスチャ上に対応する位置情報が drawables に記述されています。というわけで、この情報通りにテクスチャからパーツを取り出してcanvasに貼り付けてみます。

// テクスチャ画像をcanvasで用意
const texture = document.createElement("canvas");
...

// パーツ描画用のcanvas
const offCanvas = document.createElement("canvas");
const ctx = offCanvas.getContext("2d");

// Path2Dにメッシュの三角形の数だけパスを追加
const path = new Path2D();
for (let i = 0; i < indexCount; i += 3) {
  const [p, q, r] = indexArray.slice(i, i + 3);
  const [px, py] = uvArray.slice(p * 2, (p + 1) * 2);
  const [qx, qy] = uvArray.slice(q * 2, (q + 1) * 2);
  const [rx, ry] = uvArray.slice(r * 2, (r + 1) * 2);

  // Live2Dは左下が原点の座標系なのでy方向に反転
  path.moveTo(px * w, (1 - py) * h);
  path.lineTo(qx * w, (1 - qy) * h);
  path.lineTo(rx * w, (1 - ry) * h);
  path.closePath();
}
// Path2Dの形にくり抜いてテクスチャをコピー
ctx.clip(path, "nonzero");
ctx.drawImage(texture, 0, 0);

作成したパーツのcanvasdrawables の情報を元に配置させると…

f:id:spring_raining:20200125181049p:plain

これは失敗例 パーツは重ね順の指定もあるので、z-index を使って制御します。

f:id:spring_raining:20200125180657p:plain

今度は成功しました! この段階ではJavaScriptを使っていますが、パーツ画像をData URLか何かで書き出してやればJavaScript無しで表示させることも出来るでしょう。ただし、これだけではまだキャラクターは動かせません。

モデルを動かす

Source: CSSLive2D/src/02_morph at master · spring-raining/CSSLive2D · GitHub

次にモデルのアニメーションに挑戦します。これまでは各パーツを画像として切り出したので、次はメッシュの三角形単位で画像化して…といきたいところだったのですが、ポリゴン(メッシュ中の三角形)の数が14088もあるモデルをすべて変換すると、重すぎて使い物にならないことが発覚します。そこで、方針を変換してSVG<ClipPath /><use /> を使ってレンダリングを試みます。

まず、SVG内でテクスチャをSymbolとして定義します。これをSVG内の各ポリゴンで参照することで、ポリゴン毎にテクスチャを持つことによるコストを削減します。

<defs>
  <symbol id="texture_00" width="2048" height="2048">
    <image xlink:href="resources/Haru/Haru.2048/texture_00.png"></image>
  </symbol>
  <symbol id="texture_01" width="2048" height="2048">
    <image xlink:href="resources/Haru/Haru.2048/texture_01.png"></image>
  </symbol>
</defs>

テクスチャからポリゴンを切り出すための <ClipPath /> を各ポリゴン用に用意します。idを適当に割り振っておき、後で参照できるようにします。

<defs>
  <clipPath id="clip_15e573e1d9f758efbe2ad2cab2d4ed2f">
    <path d="M 1740.155029296875 1379.649169921875 L 1705.1416015625 1358.64111328125 L 1666.2769775390625 1385.0565185546875 Z"></path>
  </clipPath>
  <clipPath id="clip_b67855933fef072af8a663c7f97e77">
    <path d="M 1600.101318359375 1393.654541015625 L 1666.2769775390625 1385.0565185546875 L 1635.11474609375 1363.3095703125 Z"></path>
  </clipPath>
  ...
</def>

<use/> から定義したテクスチャを参照し、clip-path で切り抜きたい形を指定することで、ポリゴン1つを表現できます。

<use xlink:href="#texture_00" clip-path="url(#clip_15e573e1d9f758efbe2ad2cab2d4ed2f)"></use>
<use xlink:href="#texture_00" clip-path="url(#clip_b67855933fef072af8a663c7f97e77)"></use>
...

すべてのポリゴンを <use/> に変換すると、ちゃんと各パーツのテクスチャが表示されました。ポリゴン同士で微妙に隙間が空いているのが残念…

f:id:spring_raining:20200125184957p:plain

デフォーマ

Live2Dではパーツの変形を制御するものとして デフォーマ を提供しています。デフォーマは制御点を使ってパーツや他のデフォーマの変形を定義し、デフォーマ同士に親子関係を作ることで親デフォーマの変形が小デフォーマに影響する複雑なアニメーションを表現しています。

f:id:spring_raining:20200125185637g:plain

しかし、本来ならmoc3データに入っているはずのデフォーマに関する情報が、Live2DのSDKではJSから見ることが出来ない… 意図的に秘匿しているっぽい感じもあるので、諦めて自分でデフォーマの情報を書くことにします。

デフォーマには 回転デフォーマワープデフォーマ の2種類があり、それぞれ実現可能な変形が異なります。回転デフォーマはその名の通りパーツを回転させるもので、中心点と変形可能な角度の範囲を指定します。ワープデフォーマは、ある四角形の領域を縦と横方向に一定数分割し、田の字になったデフォーマの各頂点を動かすことで、その領域にあるポリゴン頂点の移動を補完するものです。*1 今回用意したデフォーマの定義は、このあたりからなんとなく読み取れると思います。

ところで、SVGで用意した各ポリゴンはまだ本来の位置に配置できていません。ポリゴンをシステマティックに配置するため、ここでCSS transformの matrix() 関数を使います。

CSSのmatrix関数は6つのパラメータを使って2次元変換行列を指定し、その値に従って要素を変形させます。ポリゴン3点のそれぞれの移動元と移動先座標が分かればこのパラメータが決定するので、下記の式に従って全てのポリゴンのmatrix変形をCSSに追加します。

f:id:spring_raining:20200125195804p:plain:w300

このとき、


\left( \begin{array}{cc}
a & b \\\
c & d \\\
tx & ty
\end{array} \right) = \left( \begin{array}{ccc}
x_1 & y_1 & 1 \\\
x_3 & y_3 & 1 \\\
x_5 & y_5 & 1
\end{array} \right) ^{-1} \left( \begin{array}{cc}
x_2 & y_2 \\\
x_4 & y_4 \\\
x_6 & y_6
\end{array} \right)

さらに、ここから先程用意したデフォーマ情報をもとに変形させる処理を加えます。各デフォーマの階層だけ <g> タグを入れ子にし、そのデフォーマを変形させる場合は <g> タグ全体に対し変形させるようにします。とても都合の良いことに、SVGは複数のtransformを子の定義から順に適用してくれるため、これで上手くいきます。

<!-- 親の回転デフォーマ -->
<g style="transform: rotate(-0.9597992411402052deg); transform-origin: 0px 1003.52px;">
  <!-- ポリゴンの変形 -->
  <g style="transform: matrix(0.934, 0.058, -0.039, 0.919, -1878.59, -910.749);">
    <!-- 実際のテクスチャ -->
    <use ... />
  </g>
  ...
</g>

そして、アニメーションのために window.requestAnimationFrame 内でCSSを書き換える処理を書きます。

const cb = (time) => {
  const angle = Math.sin(time / 1000);
  ...
  element.style.transform = getMatrix(...);
  window.requestAnimationFrame(cb);
};
window.requestAnimationFrame(cb);

数多のバグを乗り越え、実装が完成するとこのように動かすことができます。

f:id:spring_raining:20200125205922g:plain

首が左右に動く! 首の揺れに応じて髪がなびく!! 右のもみあげがバグで千切れてるしすごいカクカクしてますが見なかったことにします。

CSS Animationで動かす

Source: CSSLive2D/src/03_css_animation at master · spring-raining/CSSLive2D · GitHub

JSからCSSのパラメータを操作してアニメーションさせるところまで出来たので、あとはこれをCSS Animationで表現するように修正します。といっても、アニメーションのキーフレームとなる変形は全て判明しているので、あとは各ポリゴンに以下のような@keyframesを用意して置き換えるだけです。なお、ここで使用している cubic-bezier(0.34, 0, 0.64, 0.43)cubic-bezier(0.36, 0.57, 0.66, 1) は正弦波を再現したものです。

@keyframes anim_poly_a13b3d4032780bcccd23a9bd5d4a4b {
  0% {
    /* アニメーション開始時の変形 */
    transform: matrix(0.958,0,0.037,1,-946.294,-225.005);
    animation-timing-function: cubic-bezier(0.34, 0, 0.64, 0.43);
  }
  50% {
    /* アニメーション中間点の変形 */
    transform: matrix(1,0,0,1,-932,-225.005);
    animation-timing-function: cubic-bezier(0.36, 0.57, 0.66, 1);
  }
  100% {
    /* アニメーション終了時の変形 */
    transform: matrix(0.958,0,-0.063,1,-811.153,-225.005);
  }
}
[data-poly="poly_a13b3d4032780bcccd23a9bd5d4a4b"] {
  animation: 2s linear 0s infinite alternate anim_poly_a13b3d4032780bcccd23a9bd5d4a4b;
}

ついに完成です。最後にアニメーション結果を表示させてみましょう…!

f:id:spring_raining:20200125211634g:plain

え!?!?!?

なんで…………?

どうやら、ブラウザは一定数のDOMの読み込みを終えてしまうと、その時点で画面へ表示されアニメーションが開始してしまうようです。これでは、全てのポリゴンを同時にアニメーションさせることが出来ません。不本意ですが、以下のスニペットで解決させます。

<style>
  .loading * {
    animation-play-state: paused !important;
  }
<style>
<body class="loading" onload="this.document.body.classList.remove('loading')">
...

というわけで、こちらが最終成果です。JSでアニメーションさせるよりもかなりパフォーマンスが上がっていますが、No JSでアニメーションさせるという野望は叶いませんでした。

f:id:spring_raining:20200125213034g:plain

まとめ

色々厳しすぎるので、大人しくWebGLで動く公式のSDKを使ったほうが良いと思います。

Live demo

spring-raining.github.io

Repository

github.com

*1:本来のワープデフォーマは制御点の分割数と補完領域の分割数が独立していたり、補完の方法も線形ではなかったりともっと複雑ですが、今回は単純な線形補間としています