【Unity】3Dモデルのスキニングを理解する

# はじめに
この記事は、Unityを通して3Dモデルのスキニングを理解するためのものです。（若干エンジニア向けな内容です。）
まず、3DCGでは3Dモデルを動かす際にボーンを使ってメッシュを変形させる**スキニング**という手法が一般に用いられます。この記事ではUnityでのスキニングを行っている**SkinnedMeshRenderer**を例に、どのようにスキニングが行われているかを説明します。
実行環境は以下の通りです。
|項目|バージョン|
|-|-|
|OS|Windows 11|
|Unity|Unity 2022.3.1f1|
|Unity Rendering Pipeline|Built-in|
|Blender|Blender 3.4|

# スキニングのアルゴリズムの種類
一口にスキニングと言っても、様々な種類があります。
まず、**LBS（Linear Blend Skinning）** は非常に簡単で計算コストも少ないアルゴリズムで、Unityや多くの3DCGソフトではLBSに基づいてスキニングが行われています。他にも、LBSで腕を大きく曲げた際に肘の体積が減ってしまう問題を解決するために発案された**DQS（Dual Quaternion Skinning）**[^dqs]や**SBS（Spherical Blend Skinning）**[^sbs]等もありますが、Unity標準で用意されていないため今回は扱いません。
[^dqs]:[デュアルクォータニオン　スキニング　チュートリアル・解説 C++ - Rodolphe Vaillant's homepage](http://rodolphe-vaillant.fr/entry/130/dual-quaternions-skinning-tutorial-and-cpp-codes-japanese)
[^sbs]:[Spherical Blend Skinning](https://users.cs.utah.edu/~ladislav/kavan05spherical/kavan05spherical.html)

# LBS（Linear Blend Skinning）
LBSを説明するには、**ボーン**・**スケルトン**・**ウェイト**・**バインドポーズ**を理解している必要があります。まず、それぞれの用語について簡単に説明します。

## ボーン・スケルトン
**ボーン**とは、人体の骨のようなもので、ボーンを動かすとそれに応じてメッシュの頂点が変形します。
また、複数のボーンが集まった骨格のようなものを**スケルトン**と呼びます。
![](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/6ee4cd2d-d601-0c57-146c-dbff7899710e.png)

## ウェイト
**ウェイト**とは、各頂点が**ボーンの移動・回転（・拡大縮小）の影響をどの程度受けるか**を表すものです。Blenderでは赤が最も影響を受けていて（ウェイトが1）、青が最も影響を受けていない（ウェイトが0）というように表示されます。
各頂点でのウェイトは、影響を受けている全てのボーンのウェイトを足すと**必ず1になる**ように正規化されます。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/0a0240ab-9a85-27ff-1727-9d7db6600634.png)

## バインドポーズ
**バインドポーズ**とは、モデルのメッシュにスケルトン（ボーン）を紐づける（バインドする）ときのポーズです。人体の場合、バインドポーズには主に腕の角度の違いによって**Tスタンス**と**Aスタンス**に分けられます。（画像はスケルトンがTの字をしているため**Tスタンス**です。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/7ccd79d8-8dc3-10b8-4cd0-56100c0542e1.png)

## アルゴリズム
### 各頂点の座標の計算方法
LBSでのメッシュの各頂点の座標は次のように求めることができます。
```math
\begin{equation}
\begin{aligned}
v' &= \sum_{i=0}^{n-1} w_i B'_i B_i^{-1} v
\qquad
\textrm{where} \quad \sum_{i=0}^{n-1} w_i = 1, w_i>0
\end{aligned}
\end{equation}
```

| 記号 | 意味 |
| --- | --- |
| $v'$ | LBS後の頂点の座標 |
| $n$ | 頂点に対して影響を与えるボーンの数 |
| $w_i$ | 頂点vに対するi番目のボーンの影響度 |
| $B'_i$ | 現在のi番目のボーンの変換行列 |
| $B_i^{-1}$ | バインドポーズ時のi番目のボーンの逆変換行列 |
| $v$ | バインドポーズ時の頂点の座標 |

### 各頂点の座標の計算方法（具体例）
数式だけを見ても分かりづらいと思うので具体例を交えて説明します。

:::note warn
この説明では簡単のためx-y平面での座標と回転のみを考えていますが、実際は変換行列を用いるため拡大縮小も考慮されます。
:::

#### 0. バインドポーズ時状態
バインドポーズ時の頂点の座標 : $ v = (2, 1)^\top $
頂点に対して影響を与えるボーンの数 : $ n = 1 $（$b_0$のみ）
頂点vに対する0番目のボーンの影響度 : $ w_0 = 1 $
バインドポーズ時のボーンの座標 : $ b_0 = (1, 0)^\top $
バインドポーズ時のボーンの回転（z軸周り） : $ \theta = -90 $
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/66bf026a-2529-6158-12c5-4e3d2fb982dc.png)


#### 1. バインドポーズ時のボーンを原点で回転が0になるようにする
<font color="Green">$ B_i^{-1} v $ : バインドポーズ時のボーンを原点で回転が0の状態にしたときの頂点の座標</font>

つまり、$v$に対して以下の操作を行う
$-b_0 = -(1, 0)^\top$移動する
$$v-b_0 = (2, 1)^\top - (1, 0)^\top = (1, 0)^\top$$
z軸周りで$-\theta=90$回転させる
```math
R(-\theta)(v-b_0) = R(90)(1, 0)^\top = 
\begin{pmatrix}
\cos{90} & -\sin{90} \\
\sin{90} & \cos{90}
\end{pmatrix}
(1, 0)^\top
=
(-1, 1)^\top
```
よって、
<font color="Green">$$B_0^{-1} v = R(-\theta)(v-b_0)  = (-1, 1)^\top $$</font>となる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/a331486d-9019-e468-e48a-ff87ff959500.png)

#### 2. 1.で原点に移動させたものを現在のボーンの座標に移動する
<font color="DarkOrange">$ B'_i B_i^{-1} v $ : 1.で原点に移動させたものを現在のボーンの座標に移動した座標</font>

つまり、$B_0^{-1} v$に対して以下の操作を行う
z軸周りで$\theta'=-45$回転させる
```math
R(\theta')R(-\theta)(v-b_0) v = R(-45)(-1, 1)^\top = 
\begin{pmatrix}
\cos{-45} & -\sin{-45} \\
\sin{-45} & \cos{-45}
\end{pmatrix}
(-1, 1)^\top
=
\left(0, \sqrt{2}\right)^\top
```
$b'_0 = (1, 0)$移動する
```math
R(\theta')R(-\theta)(v-b_0) v + b'_0
=\left(0, \sqrt{2}\right)^\top + (1, 0)^\top
=\left(1, \sqrt{2}\right)^\top
```
よって、<font color="DarkOrange">$$v'=B'_i B_i^{-1} v = R(\theta')R(-\theta)(v-b_0) v + b'_0 = \left(1, \sqrt{2}\right)^\top\approx(1,1.41)^\top
$$</font>となる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/0c96b9ec-614a-2b3d-e9ab-847422300d08.png)

#### 3. 1~2.をボーンの数だけ足し合わせる
<font color="DarkOrange">$ v' = \sum_{i=0}^{n-1} w_i B'_i B_i^{-1} v $ : 1~2.をウェイトで重み付けした重み付け和</font>

今回は$v$に影響するボーンは$B_0$のみで$w_0=1$なので、<font color="DarkOrange">$v'\approx(1,1.41)^\top$</font>となる。
以上で、バインドポーズ時の頂点<font color="Blue">$v'=(2,1)^\top$</font>から現在のボーンの状態の頂点<font color="DarkOrange">$v'\approx(1,1.41)^\top$</font>を計算することができる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/0238b6d1-89b2-dab2-4c7b-fd0d880d7127.png)

# Unityでの実装
LBSでのスキニングをUnity C#で実装してみました。
コードの全体は以下のGistを参照してください。
SkinnedMeshRendererと同じGameObjectにアタッチすると動作します。
https://gist.github.com/mkc1370/665c29e0b76d3d81bf45f4c6ff303641

## コードの説明
先程の式をUnityっぽく（？）書くと次のようになります。
$$ \textrm{vertex} = \sum_{i=0}^{n-1} \textrm{weight[i]} * \textrm{bones[i].localToWorldMatrix}(\textrm{bindposes[i]}(\textrm{bindPoseVertex})) $$

| 記号 | 意味 |
| --- | --- |
| vertex | LBS後の頂点の座標 |
| n | 頂点に対して影響を与えるボーンの数（今回は4） |
| weight[i] | 頂点vに対するi番目のボーンの影響度 |
| bones[i].localToWorldMatrix | 現在のi番目のボーンの変換行列 |
| bindposes[i] | バインドポーズ時のi番目のボーンの**逆変換行列**[^bindposes] |
| bindPoseVertex | バインドポーズ時の頂点の座標 |

Gistの内容と若干異なりますが、メインのLBSの処理内容は以下のコードのようになっています。また、ここでは頂点の座標のみに対してLBSを行っていますが、Gistでは法線に対してもLBSを行っています。
```cs
// LBS（Linear Blend Skinning）でのスキニング
for (var vertexIndex = 0; vertexIndex < _currentVertices.Length; vertexIndex++)
{
    // 初期化
    _currentVertices[vertexIndex] = Vector3.zero;
    
    // 1頂点あたりのボーンのインフルエンス数を5個以上にするとAPI的に処理が面倒なので、簡単のために4個までに制限
    var maxBoneWeightCount = 4;
    for (var i = 0; i < maxBoneWeightCount; i++)
    {
        // 頂点に影響するボーンの情報を取得（forで回すためにGetBoneWeightでラップしている）
        var (boneIndex, weight) = GetBoneWeight(vertexIndex, i);
        
        // 頂点の座標のブレンド
        _currentVertices[vertexIndex] +=
            weight * _bones[i].localToWorldMatrix.MultiplyPoint(mesh.bindposes[boneIndex].MultiplyPoint3x4(_bindPoseVertices[vertexIndex]));
    }
}
```
[^bindposes]:[Mesh-bindposes - Unity スクリプトリファレンス](https://docs.unity3d.com/ja/2021.2/ScriptReference/Mesh-bindposes.html)
## 単純なモデルでの動作例
簡単のため、頂点が4個だけのメッシュ（トポロジはLines[^lines]）を用意しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/48fb35ad-992c-b218-f1bc-58fbd1be43a4.png)
ボーンの座標・回転は画像のとおりです。ただし、それぞれの頂点のウェイトは表のように**ボーンから離れるにつれて小さくなる**ように設定しました。
|ボーン|v0|v1|v2|v3|v4|
|:-:|-:|-:|-:|-:|-:|
|**B0**|1|0.75|0.5|0.25|0|
|**B1**|0|0.25|0.5|0.75|1|
* バインドポーズ時
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/4e824e9a-d087-2061-cd86-fd7ead4f7f5a.png)
* B1を45度回転させた場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/a4e16dc4-e714-083d-8aed-1694c5f428fb.png)
* B0を45度、B1を-45度回転させた場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/4f016ecd-9e1b-722a-3d82-81f662d03101.png)
いずれの場合でも、メッシュがボーンに合わせて滑らかに変形していることが分かります。
[^lines]:[MeshTopology-Lines - Unity スクリプトリファレンス](https://docs.unity3d.com/ja/2020.3/ScriptReference/MeshTopology.Lines.html)

## 3Dモデルでの動作例
ユニティちゃんの`unitychan_SLIDE00`のモーション[^motion] © UTJ/UCL でスライディングしている様子です。ぱっと見た限りでは同じスキニングを再現できていることが分かります。実際に各頂点の座標の二乗平均平方根誤差を求めたところ、`2.64452571331574E-08`であったため計算の誤差の範囲内だと思います。
左がUnityの`SkinnedMeshRenderer`、右が自作の`MySkinnedMeshRenderer`。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/8f1b01d9-8f46-0d2f-1c93-3b792238c0ec.png)
[^motion]:[ユニティちゃん 3Dモデルデータ - ダウンロード - UNITY-CHAN! OFFICIAL WEBSITE](https://unity-chan.com/download/releaseNote.php?id=UnityChan)

# まとめ
今まで何となく理解した気になっていたスキニングでしたが、実際に処理内容を見てみると思ったよりシンプルなものでした。この記事がスキニングへの理解の手助けになれたなら幸いです。

# 余談 : UnityでのスキニングはGPU？CPU？
ときどき話題に上がる内容ですが、少なくともUnity 2022.3.1f1ではデフォルトで**GPU**でスキニングが行われます。設定やグラフィックAPIによってはCPUでスキニングが行われることがあります。[^setting]（結構昔からUnityのGPUでのスキニングは何かとバグを抱えていたことが多かった印象ですが、最近は安定している気がします。）
[Project Settings]->[Player]->[GPU Compute Skinning]
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/6f79293b-b43d-7a9d-13c7-1750901b7e7b.png)

画像から分かるように、GPUでスキニングをする場合はスキニング（とブレンドシェイプ）の計算はコンピュートシェーダーで行われます。
実際のコンピュートシェーダーは[Unity download archive](https://unity.com/releases/editor/archive)からダウンロードすることができますので、興味のある方は見てみると面白いです。
スキニング : `DefaultResources/Internal-Skinning.compute`
ブレンドシェイプ : `DefaultResources/Internal-BlendShape.compute`
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/320192/e2b22de3-3af3-3357-1185-a17faa600c79.png)

[^setting]:[PlayerSettings-gpuSkinning - Unity スクリプトリファレンス](https://docs.unity3d.com/ja/2022.3/ScriptReference/PlayerSettings-gpuSkinning.html)