ant+ 心拍計を用いて、Unity上でVRMの表情やアニメーションを制御する。

# これがやりたい

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">ああー！顔真っ赤になっちゃった！監督曰くガーミンの心拍計と、ブレンドシェイプの組み合わせをスクリプトを使って制御してるんだって！　<a href="https://twitter.com/hashtag/Mtoon?src=hash&amp;ref_src=twsrc%5Etfw">#Mtoon</a> <a href="https://twitter.com/hashtag/ant?src=hash&amp;ref_src=twsrc%5Etfw">#ant</a>+ <a href="https://t.co/IPIsJxsyPB">pic.twitter.com/IPIsJxsyPB</a></p>&mdash; 仁志乃(にしの)@🦘🚴‍♂️サイクリストVtuber (@Nishino_cyclist) <a href="https://twitter.com/Nishino_cyclist/status/1237060993340936192?ref_src=twsrc%5Etfw">March 9, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

![心拍.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/d4552bca-0807-f71b-b8c8-d88544c7cbd2.png)

※追記※[心拍ゾーンとは何ですか？ | Garmin サポートセンター](https://support.garmin.com/ja-JP/?faq=1BuYR3HJlR2QSALSOag8C7)

誰しも心拍によって顔が赤くなったり元に戻ったりしたい筈。したいですね？
(心拍数センサーの入力によって、VRMモデルのブレンドシェイプの数値を変動させたい)

この記事は知識のない者が感覚でばーっとやって、びゃーとした感想を書いています。
分からないなりに頑張って書いたから誉めてくださいお願いします。間違ってたら指摘をお願い致します。

---

# この記事を書こうと思ったきっかけ
私はサイクリストバーチャルyoutuber仁志乃ちゃんの監督です。

https://twitter.com/Nishino_cyclist
普段はかわいいカンガルー🦘の女の子にYoutubeで雑談させたり自転車させたりしています。
やっぱスポーツ頑張ってたら汗かいたり暑くて顔が赤くなったりするよね。
スポーツ自転車乗ってる人なら結構持ってるant+ の心拍計と連動して
VRMモデルの顔色を変えるみたいなことをやりたくなりました。


---
# 用意するもの

---

## 環境
### Uniy 2018.4.10f1
※私の環境なので他の環境でも出来ると思います。
### [Univrm v0.53.0](https://github.com/vrm-c/UniVRM/)
今回はあくまでも心拍とVRMに関して書くのでアバターとして動かしたい場合は別途[バーチャルモーションキャプチャー](https://sh-akira.github.io/VirtualMotionCapture/)などを使用してください。無料です！
### [AdvancedAnt+](https://assetstore.unity.com/packages/tools/network/advanced-ant-71980)
有料アセットです。$60です！プログラミングわかんないのにこれ使ったら秒ですよ最高。
このアセットでant+のデータを取得できるようになります。今回心拍数だけですが、ケイデンス(自転車のクランクの回転数)やスピード、パワー（ワット数）なんかも取れます。

### 赤面表情差分があるVRMモデル
今回は冒頭画像のようにゾーン2~4では頬付近だけをだんだん赤らめ、4～5では顔のメッシュ全体を赤くしていきたいので、
半透明のテクスチャを乗せたメッシュを仁志乃ちゃんに追加しました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/ee7c851a-ba63-7e44-ae65-c3f7f33ff916.png)


---
## AdvancedAnt+を使うには開発者登録が必要

[AdvancedAnt+っていうUnityのアセットについて](https://vcycling.hatenablog.com/entry/2018/08/30/031052)
AdvancedAnt+アセットの導入について書かれています。
 >サイトにて開発者登録が必要です(無料)

とありますので、登録します。登録時のことに関して補足すると、サイトにアクセスしたら、右上のregister now（赤丸）をクリックしてCREATE A USER ACCOUNTに行き、登録を行いましょう。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/d88d1a48-4037-97d8-1da9-e3d26291ea40.png)

>*ACTIVATION IS NOT AUTOMATIC AND WILL TAKE UP TO 1 BUSINESS DAY, NOT INCLUDING STATUTORY HOLIDAYS*

登録ページにこう書かれてある通り、自動返信で即返事が来るわけではないので注意。
私は土日を挟んで月曜には登録完了メールが届きました。
早速ログインして、
https://www.thisisant.com/developer/ant-plus/ant-plus-basics/network-keys
のページにアクセスし、下段のこの部分、赤丸のところのネットワークキーを控えます。
（後ほど使用します。）

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/0e1133f9-44a3-224e-c80c-44827d1d2adc.png)

---

#### 別の手段で取得？
私はアセットを使いましたが、こちらの記事では御自身で心拍を取れるようにされているみたいです。
[ANT+で脈拍数を取得してUnityで作ったVR空間に表示させる](https://qiita.com/GenkiOkuma/items/bc0469fc3b29ed2f88cf)

---

## 機材
### [GARMIN(ガーミン) ハートレートセンサー HRM-Dual](https://store.garmin.co.jp/shop/ProductDetail.aspx?sku=010-12883-01&CD=F1000067&WKCD=)
私と仁志乃は胸に巻くタイプの心拍計を使用しています。
上記はant+とBluetooth両方使えるタイプですが、必ずしもこれでなくても大丈夫。
リストウォッチタイプの心拍転送モードでもできるはず
GARMINのvívosmartという腕時計タイプの活動量計でも検証しましたが、使えました。（心拍転送モードにしてね）
### [USB ANTスティック mini](https://www.garmin.co.jp/products/accessories/010-01058-00_010-02061-42/)
パソコンにぶっさしてant+のデータを受け取る方。

---
# UnityにAdvancedAnt+とUnivrmを入れる

二つともUnitypackageファイルをインポートするだけでOK

---

# ネットワークキーを有効にする。
AntManager
`Assets`/`AdvancedAnt`/`Plugins`/`Ant`/`AntManager.cs`を開き、

42行目に先ほど控えたネットワークキーをコピペします。

```AntManager.cs
readonly byte[] NETWORK_KEY = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };// COPY THE CORRECT NETWORK KEY HERE
```

このへんのところ`Assets`/`AdvancedAnt`/`PC_MAC_readme.txt`　にも書かれています。

---
# demowithPrefabsシーンを試す
それではUSB ANTスティックをPCに刺して認識させてましょう。
そして心拍計を体に装着します。機材の準備が整ったら、Unityに戻って
`Assets`/`AdvancedAnt`/`demowithPrefabs`/`demowithPrefabs`を開きます
hierarchyの`HeartRateDisplay`をアクティブにしてからシーンを再生。
心拍の情報がUnity届いていることを確認しましょう。届いているなら再生前は
Enable the gameObjects you would like to use
と表示されている部分が変化し、心拍数（HR = XX）が表示されます。
![HeartRateDisplay](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/caa062aa-2a23-2ea6-105b-57495e4c32e1.png)

うまくいったところで、このシーンを別名保存してVRMを読み込みましょう。

---
# VRMのblendshape
UniVRMの[ブレンドシェイプの設定](https://vrm.dev/univrm/components/univrm_blendshape/)を見てください。
[オプション]表情を追加する　という項目がありますのでちょっと読んでみてください。メッシュだけではなく
テクスチャを変化させられるのが分かります。
_colorのTarget Valueをピンクに変えましょう。これで心拍ゾーン4～5の、顔全体が真っ赤にする方法が何となくわかるかと思います。（汗は思い付きで入れましたがこれは普通のブレンドシェイプ）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/c559d16c-6bfe-9976-4c40-fd2c8fbc4aa7.png)

心拍ゾーン3～4では頬のテクスチャを`NEUTRAL`表情の時は_colorのアルファ0にしておき、`赤ら顔`表情の時にアルファ100になるようにしています
//![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/d310f3d3-dbcb-161f-1520-5a8669ed0247.png)
![コメント 2020-03-16 214929.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/21731d85-49a6-b2fd-90b5-04cd34cddb68.png)


---

# 顔色を操作するスクリプトをVRMにアタッチする
以下のスクリプトをVRMにアタッチしてください。先程作ったオプションの表情のBlend Shape nameを直接指定してください。

```kaoiro.cs
using UnityEngine;
using System.Collections;
using ANT_Managed_Library;
using VRM;

public class kaoiro: MonoBehaviour
{

    GameObject HeartRateDisplay; //GameobjerctとしてのFitnessEquipmentDisplayが入る変数
    HeartRateDisplay script; //FitnessEquipmentDisplayScriptが入る変数
    BlendShapePreset currentFace;
    
    private VRMBlendShapeProxy proxy;
    private CharacterController characterController;
    private Vector3 velocity;
    public float maxheart = 200f;//最大心拍数
    public float minheart = 60f;//安静時心拍数
    

    [SerializeField]
    void Start()
    {

        HeartRateDisplay = GameObject.Find("HeartRateDisplay"); //FitnessEquipmentDisplay をオブジェクトの名前から取得して変数に格納する
        script = HeartRateDisplay.GetComponent<HeartRateDisplay>(); //FitnessEquipmentDisplay の中にあるFitnessEquipmentDisplay を取得して変数に格納する
        var proxy = GetComponent<VRMBlendShapeProxy>();

        // Blend Shape Nameを指定して適用量をセット
        proxy.SetValue("赤ら顔", 0f);

    }

    void Update()
    {
        if (proxy == null)
        {
            proxy = GetComponent<VRMBlendShapeProxy>();
        }
        else
        {
            float faceCollor1 =  maxheart - minheart;//計算の順序わからなくて無理やり
            float faceCollor = 1f / faceCollor1;//やりたいのは平常時心拍と最大時心拍の間を0～1で変化させたかった。
            float heartRate = script.heartRate;//scriptで持ってきた心拍数を代入


            if (heartRate < maxheart * 0.6f)//心拍ゾーン1（～60％）
            {
                proxy.SetValue("赤ら顔", faceCollor *heartRate *0.5f);
                proxy.SetValue("顔真っ赤", faceCollor * heartRate * 0f);

            }
            else if (heartRate < maxheart * 0.8f)//心拍ゾーン2～3（～80％）
            {
                proxy.SetValue("赤ら顔", faceCollor * heartRate);
                proxy.SetValue("顔真っ赤", faceCollor * heartRate * 0.5f);
            }
            else if (heartRate < maxheart * 0.9f)//心拍ゾーン4～5（90％～）
            {
                 proxy.SetValue("赤ら顔", faceCollor * heartRate);
                proxy.SetValue("顔真っ赤", faceCollor * heartRate *1.1f);
            }

        }
    }
}
```

そしたらもう表情変わりますよ！！ぴゅんですよ！！やったね
（画像でセンサーが取得出来てないのはわざとです。即座に自分の心拍を上昇できなかったので`HeartRateDisplay`を直接数字いじりました。）
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/61ffefb7-e898-ac57-c356-f2f4d5ba13ef.png)

---
# ハートの鼓動アニメーションを連動させたい
書き忘れました。
冒頭のツイッターの動画右上に鼓動するハートのアニメがあります。これは取得している心拍とシンクロしています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/ed035a48-400a-9220-06cb-9cfa52beaea7.png)

これを実装しましょう。
[[Unity] Canvasに Image 画像を配置しScriptで変更](https://uni.gas.mixh.jp/unity/canvas-text-image.html)
[【Unity】uGUIでスプライトアニメーションするには](http://tsubakit1.hateblo.jp/entry/2016/01/29/000017)
こちらの記事がとても参考になりました。

1秒間に1回ドクンとするAnimationClipを作成し、ドキドキさせたいGUIのimageにアタッチします。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/600172/ccf9ce9c-52cb-1213-a903-87e82c7a8a04.png)

次に、下のスクリプトをアタッチします。

```kokoro_Move_speed.cs
using UnityEngine;
using System.Collections;
using ANT_Managed_Library;
using UnityEngine.UI;

public class kokoro_Move_speed : MonoBehaviour
{
    GameObject HeartRateDisplay; //GameobjerctとしてのFitnessEquipmentDisplayが入る変数
    HeartRateDisplay script; //FitnessEquipmentDisplayScriptが入る変数

    private CharacterController characterController;
    private Vector3 velocity;
    [SerializeField]
    private float speed;
    private Animator animator;



    void Start()
    {


        HeartRateDisplay = GameObject.Find("HeartRateDisplay"); //FitnessEquipmentDisplay をオブジェクトの名前から取得して変数に格納する
            script = HeartRateDisplay.GetComponent<HeartRateDisplay>(); //FitnessEquipmentDisplay の中にあるFitnessEquipmentDisplay を取得して変数に格納する


        animator = GetComponent<Animator>();
        animator.speed = 1.0f; //1秒に1回鼓動するアニメーションの場合

        //heartRate数値に合わせて段々とimageの色を赤く変更する準備
        //GetComponent<Image>().color = new Color(1.0f, 1.0f, 1.0f, 1.0f);
    }

    void Update()
    {
        float heartRate = script.heartRate;//scriptで持ってきた心拍数を代入
                                           //Debug.Log("心拍数は" + heartRate);

        characterController = GetComponent<CharacterController>();
        animator = GetComponent<Animator>();
        animator.speed = 1.0f / 60.0f * heartRate;
        //image色変更　
        //GetComponent<Image>().color = new Color(1.0f , 1-(0.0025f * heartRate), 1 - (0.0025f * heartRate), 1.0f);



    }
}
```
シーン再生するともう完成です。

---

# 2025年追記 uniVRM v0.127.0　では下記のようにした。
BlendShapeの仕様も変わっているので変更。VRMをgameobjectで指定する形式に変更。
```kaoiro.cs
using UnityEngine;
using VRM;
public class Kaoiro : MonoBehaviour
{
    [SerializeField]
    private GameObject vrmObject; // インスペクターでVRMのGameObjectを指定
    private VRMBlendShapeProxy proxy;
    [SerializeField]
    private GameObject HeartRateDisplay; // インスペクターでHeartRateDisplayオブジェクトを指定（任意）
    private HeartRateDisplay script; // HeartRateDisplayスクリプト
    public float maxHeart = 200f; // 最大心拍数
    public float minHeart = 60f; // 安静時心拍数
    private BlendShapeKey blushKey = BlendShapeKey.CreateUnknown("赤ら顔");
    private BlendShapeKey redFaceKey = BlendShapeKey.CreateUnknown("顔真っ赤");
    
    // 現在の表情の値
    private float currentBlushValue = 0f;
    private float currentRedFaceValue = 0f;
    
    // 目標値
    private float targetBlushValue = 0f;
    private float targetRedFaceValue = 0f;
    
    // 補間速度（インスペクターで調整可能）
    [SerializeField]
    private float lerpSpeed = 5f;

    // 前回の心拍数を保持
    private float lastHeartRate = 0f;

    void Start()
    {
        // VRMBlendShapeProxyを取得
        if (vrmObject == null)
        {
            Debug.LogError("VRMオブジェクトがインスペクターで指定されていません。");
            return;
        }
        proxy = vrmObject.GetComponent<VRMBlendShapeProxy>();
        if (proxy == null)
        {
            Debug.LogError("指定されたVRMオブジェクトにVRMBlendShapeProxyが見つかりません。");
            return;
        }
        // HeartRateDisplayオブジェクトとスクリプトを取得
        if (HeartRateDisplay == null)
        {
            HeartRateDisplay = GameObject.Find("HeartRateDisplay"); // 未指定の場合はFindで検索
            if (HeartRateDisplay == null)
            {
                Debug.LogError("HeartRateDisplayオブジェクトが見つかりません。インスペクターで指定してください。");
                return;
            }
        }
        script = HeartRateDisplay.GetComponent<HeartRateDisplay>();
        if (script == null)
        {
            Debug.LogError("HeartRateDisplayスクリプトが見つかりません。");
            return;
        }
        // 初期状態で表情をリセット
        proxy.ImmediatelySetValue(blushKey, 0f);
        proxy.ImmediatelySetValue(redFaceKey, 0f);

        // 初期心拍数を設定
        if (script != null)
        {
            lastHeartRate = script.heartRate;
        }
    }
    void Update()
    {
        if (proxy == null || script == null) return;
        
        float currentHeartRate = script.heartRate;
        
        // 心拍数が変更された場合のみ目標値を更新
        if (!Mathf.Approximately(currentHeartRate, lastHeartRate))
        {
            // 心拍数に基づく計算
            float normalizedHeartRate = (currentHeartRate - minHeart) / (maxHeart - minHeart);
            float normalizedValue = Mathf.Clamp(normalizedHeartRate, 0f, 1f);
            
            // 心拍ゾーンごとの目標表情値の設定
            if (normalizedValue < 0.6f) // ゾーン1（～60％）
            {
                targetBlushValue = normalizedValue * 0.5f;
                targetRedFaceValue = 0f;
            }
            else if (normalizedValue < 0.8f) // ゾーン2～3（60％～80％）
            {
                targetBlushValue = normalizedValue;
                targetRedFaceValue = normalizedValue * 0.5f;
            }
            else // ゾーン4～5（80％以上）
            {
                targetBlushValue = normalizedValue;
                targetRedFaceValue = normalizedValue * 1.1f;
            }
            
            // 現在の心拍数を保存
            lastHeartRate = currentHeartRate;
        }
        
        // 毎フレームLerpで補間を実行
        currentBlushValue = Mathf.Lerp(currentBlushValue, targetBlushValue, Time.deltaTime * lerpSpeed);
        currentRedFaceValue = Mathf.Lerp(currentRedFaceValue, targetRedFaceValue, Time.deltaTime * lerpSpeed);
        
        // 表情の適用
        proxy.ImmediatelySetValue(blushKey, currentBlushValue);
        proxy.ImmediatelySetValue(redFaceKey, currentRedFaceValue);
    }
}
```
ハートの心拍もAnimation作らずに直接変えよう。
```kokoro_Move_speed.cs
using UnityEngine;
using System.Collections;
using ANT_Managed_Library;
using UnityEngine.UI;

public class kokoro_Move_speed : MonoBehaviour
{
    [SerializeField]
    private GameObject HeartRateDisplay; // HeartRateDisplayのGameObject
    private HeartRateDisplay script; // HeartRateDisplayスクリプト

    [SerializeField]
    private RectTransform targetSprite; // 対象のスプライト

    [SerializeField]
    private float minScale = 1.0f; // 最小スケール（k: ベースライン）
    [SerializeField]
    private float maxScale = 1.05f; // 最大スケール
    [SerializeField]
    private float curveStrength = 8.0f; // 放物線の開き具合（a）

    private Vector3 baseScale; // 元のスケールを保持
    private float pulseTimer; // 鼓動のタイミングを管理

    void Start()
    {
        script = HeartRateDisplay.GetComponent<HeartRateDisplay>();

        // 対象スプライトが指定されていない場合は警告を表示
        if (targetSprite == null)
        {
            Debug.LogWarning("対象のスプライトが指定されていません。");
            enabled = false;
            return;
        }

        // 元のスケールを保存
        baseScale = targetSprite.localScale;
        pulseTimer = 0f;
    }

    void Update()
    {
        float heartRate = script.heartRate; // 心拍数を取得
        //Debug.Log("心拍数は" + heartRate);

        // 1分間の心拍数に基づいて1拍あたりの時間を計算（秒単位）
        float beatInterval = 60.0f / heartRate;
        
        // タイマーを進める
        pulseTimer += Time.deltaTime;

        // -0.5～0.5の範囲で時間を正規化（h=0でピークが来るように）
        float normalizedTime = (pulseTimer / beatInterval) * 2 - 1;
        if (normalizedTime > 1) normalizedTime -= 2;

        // 放物線の式: y = a(x-h)^2 + k を使用
        // ここでは h=0, k=minScale, a=curveStrength
        float pulse = curveStrength * (normalizedTime * normalizedTime);
        
        // パルス値を0～1の範囲に変換
        pulse = 1.0f - Mathf.Clamp01(pulse);

        // スケールを指定範囲で変化させる
        float scaleFactor = Mathf.Lerp(minScale, maxScale, pulse);

        // スケールを適用
        targetSprite.localScale = baseScale * scaleFactor;

        // タイマーが1サイクルを超えたらリセット
        if (pulseTimer >= beatInterval)
        {
            pulseTimer -= beatInterval;
        }
    }
}
```


以上です。昨今のAIのパワー有難すぎます。
[仁志乃チャンネル](https://www.youtube.com/channel/UCnxwGgXbrAyjEogAjay-bog)

---