第1章 JINS MEMEで美少女になる話

73回生 object

1.1 自己紹介

はいどーも!73回生(高2)のobjectです!今回はJINS MEMEで美少女になる話を書きました。

1.2 JINS MEMEとは

さて、多くの人はJINS MEMEを知らないと思うので、JINS MEMEについて説明します。

まず、JINS MEMEというのはJINSが開発したメガネ型ウェアラブルデバイスです。

JINS MEME Type-HR

図1.1: JINS MEME Type-HR

シンプルな見た目でありながら、3点式眼電位センサーおよび6軸センサーを搭載しており、Bluetoothでスマホやパソコンにデータを送信できます。

3点式眼電位センサー
鼻パッドの部分にあるセンサーで視線やまばたきを検出できる
6軸センサー
x,y,z軸方向の加速度と角速度を計測できる

これら2つのセンサーによって、JINS MEMEを装着するだけで頭の動きと目の動きを取得することができます。

また、開発者向けにSDKが用意されており、有志の開発によるUnityのPluginも存在するため、これらのセンサーを活かした開発が可能です。

1.3 SDKを使う

まずはJINS MEMEのSDKを使って計測データを取得してみましょう。

JINS MEME SDKで取得可能なデータはここで確認可能です。

アプリID/Secretの取得

まず、JINS MEMEをスタートアップガイドに従って初期設定します。

次に、JINS MEMEを使ったアプリケーションを開発するにはアプリID/Secretを作成する必要があるので、アプリ作成画面から必要なことを記入してアプリを作成します。

今回はiOSで開発するのでプラットフォームはiOSに設定しておきます。

iOSでデプロイ

次に、ここからSDKをダウンロードします。

Xcodeで新規プロジェクトを作成し、ダウンロードしたフォルダの中にあるframework/universal/MEMELib.frameworkをプロジェクトにコピーします。

次に、MEMELib.frameworkをEmbedded Binariesに追加します。

Embedded Binariesに追加

図1.2: Embedded Binariesに追加

また、JINS MEMEはBLE(Bluetooth Low Energy)でスマホと通信するため、Capabilitiesの欄から、Background ModeをオンにしてBLEにチェックを付けます。

Background Mode

図1.3: Background Mode

また、Bitcodeが有効になっているとビルドに失敗するのでBuild Settingsの欄からEnable BitcodeをNoにしておきます。

次に、プロジェクト内に新規ヘッダーファイルを作成し、次のように書き加えます。

リスト1.1: MEMETest-Bridging-Header.h

#import <MEMELib/MEMELib.h>

ヘッダーファイルを作っただけでは認識されないので、Build Settingsの欄から、Objective-C Bridging HeaderにMEMETest-Bridging-Header.hを追加します。

Bridging Header

図1.4: Bridging Header

次に、アプリケーション認証をするためのコードを書きます。

AppDelegate.swiftを開いて、以下のように書き加えます。

リスト1.2: AppDelegate.swift

let appId = "appId"
let appSecret = "appSecret"

func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
  // Override point for customization after application launch.
  MEMELib.setAppClientId(appId, clientSecret: appSecret)
  return true
}

一行目と二行目に代入する値は、先程取得したアプリID/Secretで置き換えておきます。

次に、データを受信するコードを書きます。

ViewController.swiftを開いて以下のように書き加えます。

リスト1.3: ViewController.swift

import UIKit

class ViewController: UIViewController, MEMELibDelegate {

  override func viewDidLoad() {
    super.viewDidLoad()
    // Do any additional setup after loading the view, typically from a nib.
    MEMELib.sharedInstance().delegate = self
  }

  override func didReceiveMemoryWarning() {
    super.didReceiveMemoryWarning()
  }

  func memeAppAuthorized(_ status: MEMEStatus) {
    MEMELib.sharedInstance().startScanningPeripherals()
  }

  func memePeripheralFound(_ peripheral: CBPeripheral!, withDeviceAddress address: String!) {
    MEMELib.sharedInstance().connect(peripheral)
  }

  func memePeripheralConnected(_ peripheral: CBPeripheral!) {
    let status = MEMELib.sharedInstance()?.startDataReport()
    print (status as Any)
  }

  func memeRealTimeModeDataReceived(_ data: MEMERealTimeData!) {
    print (data.description)
  }

}

このコードでは、まず一回目のsharedInstance()がコールされたら先程のアプリ認証が走り、認証に成功するとmemeAppAuthorizedが呼ばれ、startScanningPeripherals()をコールして接続済みのJINS MEMEを探します。

次に、接続済みのJINS MEMEが見つかったらmemePeripheralFoundが呼ばれ、connect(peripheral)をコールしてアプリとJINS MEMEを接続します。

JINS MEMEとアプリが接続されたら、memePeripheralConnectedが呼ばれ、startDataReport()をコールするとデータを取得開始し、memeRealTimeModeDataReceivedでデータを受け取って出力します。

実はここで少し問題があり、このコードだと事前にJINS MEME公式アプリ等でiPhoneとJINS MEMEを接続しておく必要があるのですが、公式のアプリは「スタンダードモード」という一分間に一回の通信を行うモードで動いているのに対してSDKを使った独自アプリは「リアルタイムモード」のみ利用可能です。

さらに、二つのモードを同時に動かすことはできません。

つまり公式のアプリでJINS MEMEを事前に接続しつつアプリ実行時はスタンダードモードでの通信を止めておく必要があります。

そこで、公式アプリで一度接続を切ってから再接続した後、ライブビューでリアルタイムモードに切り替え、そのまま公式アプリを閉じて自作アプリを動かすとうまくいきます。

JINS MEMEからのデータを受信する

図1.5: JINS MEMEからのデータを受信する

取得したデータは図1.5のように出力されます。

1.4 UnityのPluginを使う

すでに述べたように、JINS MEMEには有志の開発によるUnityのPluginが存在します。

せっかくなのでUnityを使って開発をしていきます。

まず、ここからUnity用Pluginをダウンロードし、適当なUnityのプロジェクトを作成してMEMEUnity/Assetsフォルダ内をUnityのプロジェクトのAssetsフォルダに移動させます。

次に、READMEに書いてあるとおりにセットアップして、MEMEUnityTest/Scenes/MainをiOSでBuildします。

Buildできたら先程と同じようにJINS MEMEをiPhoneに接続してアプリを起動するとJINS MEMEの動きに合わせてCubeが動きます。

Cubeが動く

図1.6: Cubeが動く

1.5 美少女を動かす

JINS MEMEの情報をUnityで使えるようになったので、3Dモデルを動かしていきましょう。

用意するもの

  • ユニティちゃん(動かすモデル。別になんでもいい)
  • UniRx(Asset Storeからインストール)

データを受け取る

まずはJINS MEMEからデータを受け取るためのコードをUniRxを使って書きます。

リスト1.4: MyAppMEMEProxy.cs

using UnityEngine;
using System.Collections;
using UnityEngine.UI;
using UniRx;

public class MyAppMEMEProxy : MEMEProxy
{
  // 自分のAppのIDとSecretを入力
  private const string appClientId = "appId";
  private const string appClientSecret = "appSecret";

  public static MyAppMEMEProxy Instance = null;

  public Subject<MEMERealtimeData> dataStream = new Subject<MEMERealtimeData>();
  public MEMERealtimeData Data { get; private set; }

  private void Awake()
  {
    if(Instance == null){
      Instance = this;
      DontDestroyOnLoad(this.gameObject);
    }else{
      Destroy(this.gameObject);
    }
  }
  void OnEnable()
  {
    MEMEProxy.StartSession(MyAppMEMEProxy.appClientId, MyAppMEMEProxy.appClientSecret);
  }
  void OnDisable()
  {
    MEMEProxy.EndSession();
  }
  public override void Start()
  {
    base.Start();
  }
  void Update()
  {
    Data = MEMEProxy.GetSensorValues(); // ここでデータを受け取る
    if (!Data.isValid)
    {
      return;
    }
    dataStream.OnNext(Data); // データをストリームに流す
  }
}

注視方向を動かす

次に、この受け取ったデータを利用して注視方向を動かしてみます。

リスト1.5: LookAtController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using System;

public class LookAtController : MonoBehaviour
{

  Vector3 baseMemeRadius;
  Vector3 cameraPos;
  Vector3 targetPos;
  Animator animator;
  // 基準値の状態判定用
  int existbaseMemeRadius = 0;

  // Start is called before the first frame update
  void Start()
  {
    GameObject mainCamera = GameObject.FindGameObjectWithTag ("MainCamera");
    this.cameraPos = mainCamera.transform.localPosition;
    this.targetPos = cameraPos;
    this.animator = GetComponent<Animator>();

    // データを受け取る
    MyAppMEMEProxy.Instance.dataStream.Subscribe(data =>
    {
      if (existbaseMemeRadius == 0 && !Mathf.Approximately(data.pitch, 0.0f) && !Mathf.Approximately(data.yaw, 0.0f) && !Mathf.Approximately(data.roll, 0.0f) ) {
        // 基準値を設定
        baseMemeRadius = new Vector3(data.pitch, data.yaw, data.roll);
        existbaseMemeRadius = 1;
      }

      float pitchDif = baseMemeRadius.x - data.pitch;
      float yawDif = baseMemeRadius.y - data.yaw;
      float rollDif = baseMemeRadius.z - data.roll;

      // yaw の境界線の検知
      if (Math.Abs(baseMemeRadius.y - data.yaw) > 180.0f) {
        if (data.yaw > 180.0f) {
          yawDif = baseMemeRadius.y - (data.yaw - 360.0f);
        } else {
          yawDif = baseMemeRadius.y - (360.0f + data.yaw);
        }
      }

      PlayLookAt(pitchDif, yawDif, rollDif);
    }).AddTo(this);
  }

  void PlayLookAt(float pitchDif, float yawDif, float rollDif) {

    float xPos = 0.0f;
    float yPos = 0.0f;
    float zPos = 0.0f;

    if (yawDif < -120.0f) {
      xPos = -1.5f;
    } else if (yawDif > 120.0f) {
      xPos = 1.5f;
    } else {
      xPos = yawDif / 90.0f;
    }

    if (pitchDif < -120.0f) {
      yPos = -1.5f;
    } else if (pitchDif > 120.0f) {
      yPos = 1.5f;
    } else {
      yPos = pitchDif / 90.0f;
    }

    if (rollDif < -120.0f) {
      zPos = -1.5f;
    } else if (rollDif > 120.0f) {
      zPos = 1.5f;
    } else {
      zPos = rollDif / 90.0f;
    }

    Vector3 movePos = new Vector3(xPos, yPos, zPos);
    targetPos = cameraPos + movePos;
  }

  private void OnAnimatorIK(int layerIndex) {
    animator.SetLookAtWeight(1.0f, 0.2f, 1.0f, 0.2f, 0f);
    animator.SetLookAtPosition(targetPos);
  }

}

Unity上でMyAppMEMEProxy.csをMEMEProxyにアタッチして、LookAtController.csをユニティちゃんにアタッチします。

ユニティちゃんのAnimator ControllerはUnityChanLocomotionにして、カメラの位置等を調整した後iOSでビルドするとUnityちゃんがJINS MEMEの動きに合わせて動きます。

ユニティちゃんが動く

図1.7: ユニティちゃんが動く

リセットボタンを付ける

このままでは、例えばうつむいた状態でアプリを起動してしまった場合、うつむいた状態を基準として注視方向を決定するので不便です。

そこで、任意のタイミングで押すことでそのときの状態を基準値に上書きするようなボタンを作ります。

まずLookAtController.csに次のコードを追記します。

リスト1.6: LookAtController.cs

public void RestoreCenter(){
  existbaseMemeRadius = 0;
}

existbaseMemeRadiusは0と1で基準値の状態を識別します。0の場合基準値を現在のデータに上書きしてexistbaseMemeRadiusを1に戻します。

ボタンは(なぜか)最初から存在するので、コードを追記したらUnity側でボタンを押したときにこの関数を呼ぶよう設定すればリセット機能が使えます。

まばたきさせる

頭が一通り動くようになったので次は目を動かしていきます。

まずはまばたきをさせてみましょう。

リスト1.7: BlinkController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;

public class BlinkController : MonoBehaviour {

  public float blinkSpeed = 0.4f; // まばたきの時間
  private float timeRemaining = 0.0f;
  private bool timerStarted = false;
  public SkinnedMeshRenderer ref_SMR_EYE_DEF;
  public SkinnedMeshRenderer ref_SMR_EL_DEF;
  private bool isBlink = false;
  public float ratio_Close = 85.0f;
  public float ratio_HalfClose = 20.0f;
  [HideInInspector]
  public float
        ratio_Open = 0.0f;

  enum Status{
    Close,
    HalfClose,
    Open
  }

  private Status eyeStatus;

  // Start is called before the first frame update
  void Start() {
    ResetTimer();
    MyAppMEMEProxy.Instance.dataStream.Subscribe(data =>
    {
      if (data.blinkStrength >= 50) {
        isBlink = true;
      }
    }).AddTo(this);
  }

  void SetCloseEyes () {
    ref_SMR_EYE_DEF.SetBlendShapeWeight (6, ratio_Close);
    ref_SMR_EL_DEF.SetBlendShapeWeight (6, ratio_Close);
  }

  void SetHalfCloseEyes () {
    ref_SMR_EYE_DEF.SetBlendShapeWeight (6, ratio_HalfClose);
    ref_SMR_EL_DEF.SetBlendShapeWeight (6, ratio_HalfClose);
  }

  void SetOpenEyes () {
    ref_SMR_EYE_DEF.SetBlendShapeWeight (6, ratio_Open);
    ref_SMR_EL_DEF.SetBlendShapeWeight (6, ratio_Open);
  }

  void ResetTimer () {
    timeRemaining = blinkSpeed;
    timerStarted = false;
  }

  void Update () {
    if (!timerStarted) {
      eyeStatus = Status.Close;
      timerStarted = true;
    }
    if (timerStarted) {
      timeRemaining -= Time.deltaTime;
      if (timeRemaining <= 0.0f) {
        eyeStatus = Status.Open;
        ResetTimer ();
      } else if (timeRemaining <= blinkSpeed * 0.3f) {
        eyeStatus = Status.HalfClose;
      }
    }
  }

  void LateUpdate () {
    if (isBlink) {
      switch (eyeStatus) {
      case Status.Close:
        SetCloseEyes ();
        break;
      case Status.HalfClose:
        SetHalfCloseEyes ();
        break;
      case Status.Open:
        SetOpenEyes ();
        isBlink = false;
        break;
      }
    }
  }
}

ユニティちゃんには自動まばたき用のAutoBlink.csというscriptが同梱されているのですが、今回はそれを改造しています。

AutoBlink.csはランダムなタイミングでisBlinktrueにすることでまばたきさせるスクリプトなので、JINS MEMEから送られてくるまばたきの強さ(blinkStrength)の値が一定以上ならisBlinktrueにするように変えています。

このコードをユニティちゃんにアタッチした後、ref_SMR_EYE_DEFref_SMR_EL_DEFにそれぞれEYE_DEFとEL_DEF(ユニティちゃんの中にある)を参照させるとまばたきができます。

視線を動かす

次に視線を動かします。JINS MEMEでは視線の上下左右の動きを5段階の強度で取得することができるので、そのデータに合わせて視線を動かします。

リスト1.8: EyeController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UniRx;
using System;

public class EyeController : MonoBehaviour
{
  private Animator animator;
  float weight;
  Vector3 LookPos;
  public MeshRenderer eyeL;
  public MeshRenderer eyeR;
  private Vector2 eyeOffset;

  void Start()
  {
    this.animator = GetComponent<Animator>();
    eyeL = GameObject.Find("eye_L_old").GetComponent<MeshRenderer>();
    eyeR = GameObject.Find("eye_R_old").GetComponent<MeshRenderer>();
    eyeOffset = Vector2.zero;

    MyAppMEMEProxy.Instance.dataStream.Subscribe(data =>
    {
      switch (data.eyeMoveUp) {
        case 1:
          eyeOffset.y = -0.055f;
          break;
        case 2:
          eyeOffset.y = -0.1f;
          break;
        case 3:
          eyeOffset.y = -0.12f;
          break;
        case 4:
          eyeOffset.y = -0.14f;
          break;
      }
      switch (data.eyeMoveDown) {
        case 1:
          eyeOffset.y = 0.055f;
          break;
        case 2:
          eyeOffset.y = 0.1f;
          break;
        case 3:
          eyeOffset.y = 0.12f;
          break;
        case 4:
          eyeOffset.y = 0.14f;
          break;
      }
      switch (data.eyeMoveLeft) {
        case 1:
          eyeOffset.x = 0.055f;
          break;
        case 2:
          eyeOffset.x = 0.1f;
          break;
        case 3:
          eyeOffset.x = 0.15f;
          break;
        case 4:
          eyeOffset.x = 0.2f;
          break;
      }
      switch (data.eyeMoveRight) {
        case 1:
          eyeOffset.x = -0.055f;
          break;
        case 2:
          eyeOffset.x = -0.1f;
          break;
        case 3:
          eyeOffset.x = -0.15f;
          break;
        case 4:
          eyeOffset.x = -0.2f;
          break;
      }
      PlayLookEye();
    }).AddTo(this);
  }

  void PlayLookEye(){
    eyeL.material.SetTextureOffset("_MainTex", eyeOffset);
    eyeR.material.SetTextureOffset("_MainTex", eyeOffset);
  }
}

視線を動かすのにはMaterialのSetTextureOffsetというものを使って目の位置を変えています。

このスクリプトをユニティちゃんにアタッチしてeyeLeyeRにそれぞれeye_L_oldとeye_R_oldを入れると視線が動くようになります。

電池残量を表示する

ここで美少女を動かす話から少し脇道にそれますが、JINS MEMEの電池残量がわかると便利なのでそれを表示する機能をつけます。

まず電池の枠と内部の透過画像を用意してAssets内に放り込み、Texture TypeをSpriteに変更します。

次にHierarchy内でUI -> Imageと選択し、それぞれframe、batteryとしてSource Imageに対応する画像を入れます。また、中身の画像の方はImage TypeをFilledに変更し、Fill MethodをVerticalにしておきます。

次にBatteryController.csを作成し、次のように書き加えます。

リスト1.9: BatteryController.cs

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
using UniRx;

public class BatteryController : MonoBehaviour
{
  GameObject battery;

  void Start()
  {
    this.battery = GameObject.Find("battery");
    MyAppMEMEProxy.Instance.dataStream.Subscribe(data =>
    {
      this.battery.GetComponent<Image>().fillAmount = data.powerLeft * 0.2f;
    }).AddTo(this);
  }
}

fillAmountで電池残量に対応して表示が変わるようにしています。

このスクリプトをCanvasにアタッチすると電池残量に応じて表示が変わります。

左上に電池残量が表示されている

図1.8: 左上に電池残量が表示されている

リップシンクを実装する

今まででJINS MEMEから受け取れる情報のうち使えそうなものはすべて使ったのですが、口が動いたほうがよりそれっぽいと思ったので口を動かしてみたいと思います。

口を動かす方法はカメラで口の動きを認識する等があるのですが、今回はマイクで声を拾ってそれに合わせて口を動かす方法をとりたいと思います。

まず、ここからMMD4Mecanim-LipSync-Pluginをインストールしてimportします。

次に、ユニティちゃんにMMD4Mecanim-LipSync-Plugin/UnityChanLipSync.csをアタッチし、Calibrationで母音をそれぞれ録音した後、Calibrationボタンを押して、Use Micにチェックを付けると声に合わせて口が動きます。

しかし、このままではiOSでビルドしたときにマイクの使用許可を取っていないのでXcodeでエラーが出ます。そこで、info.plistにマイクの使用許可を追記することでスマホでも動くようになります。

1.6 おわりに

最後まで読んでいただき、ありがとうございます。

以上で、頭の動き、視線、瞬き、口の動きを反映させることができました。これはもう美少女になれたと言っても過言ではないでしょう(いいえ)

これを機にVTuberやバ美肉に興味を持っていただけたら幸いです。(ちなみにおすすめのVTuberは甲賀流忍者!ぽんぽこです。見て。)