Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails以外の開発一般

Flutter:スマホからVRChatの表情をコントロールする 前編

🔗目次

  1. まえがき
  2. VRChatの表情変更難しい問題
  3. OSCとはなんぞや
  4. そうだ、スマホ使おう。
  5. 実装
  6. Unity側で行う準備
  7. 完成品
  8. あとがき
    ↑目次に戻る

🔗まえがき

みなさんどうもこんにちは、株式会社ECN所属社員のFuseです。
突然ですが、皆さんはVRChat、遊んでますか?
私は前回の記事でも言及した通りドはまりしてます。
そんな私ですが、最近ある悩みがあります。
表情が覚えられません。
↑目次に戻る

🔗VRChatの表情変更難しい問題

私が使っているデスクトップ版のVRChatでは基本的にシフトキー+ファンクションキーで表情を変更します。
表情はニュートラルのF1を除くとアバターごとにF2~F8と左シフト、右シフトの組み合わせで7*2の14通あります。
さらにどの組み合わせがどの表情というのはアバターごとに違い、あるアバターでは笑顔になるキーの組み合わせがほかのアバターだと泣き顔だったりします。
アバターごとにF2~F8と左シフト、右シフトの組み合わせで7*2の14通りの表情と対応するキーを頭に叩き込み、適切なタイミングでとっさに出したり戻したりできるようになりたいのですが…
シンプルに覚えづらいです。
私は記憶が苦手で笑顔の表情(右シフト+F3)を出しているつもりが寝ぼけている感じの表情(左シフト+F3)を気づかずに出し続けたことがあります。(実話)
なんとかして簡単にボタン一つで表情を変えられれば楽なのですが…と思ったところどうやらおあつらえ向きのOSCなる仕組みがある様子、さっそく調べてみましょう。
↑目次に戻る

🔗OSCとはなんぞや

OSC(OpenSound Control)本来は、電子楽器(特にシンセサイザー)やコンピュータなどの機器において音楽演奏データをネットワーク経由でリアルタイムに共有するための通信プロトコルです。が、今回はVRChatにおけるOSCの用途についてお話します。
VRChatにおいてはアバターやワールドをさまざまな新しい方法で制御したり、VRChat からデータをストリーミングして他のものを制御する際に使われます。
例えば、外部デバイスから表情を変更したり、逆に外部デバイスにVRChat内の情報を伝えたり、現在の時間をVRChat内の腕時計で表示したりなんてこともできるみたいです。
とにかくOSCを使えば表情コントローラーは作れそうなのですがUIやほかのウィンドウで画面が隠れて見づらくなるのは困ります…
表情の名前を表示でき、ボタン感覚で操作できるデバイスといえば…
↑目次に戻る

🔗そうだ、スマホ使おう。

そうです、スマホですね。
スマホから表情を変更できるアプリを作ればPCの画面が隠れたりフォーカスをほかのアプリに移すことなくサンプル画像を見ながら表情を変更できます。
話は逸れますが、FlutterというSDKがあります。
単一のコードベースから、Android、iOS、Linux、macOS、Windows、Google Fuchsia向けのクロスプラットフォームアプリケーションを開発できる代物です。
今回はFlutterを使ってひとまず表情を変更できるようにしていきましょう。
↑目次に戻る

🔗実装

さっそく実装していく前に、作ったばかりのプロジェクトに必要なライブラリを入れていきます。
pq/osc - GitHub
Dart向けのOSCライブラリです。これを使ってスマホからPCに通信を行います。
flutter pub add osc
isar/hive - GitHub
高速で軽量なDart用KVS、Hiveです。データの永続化に使います。
flutter pub add hive
flutter pub add isar_flutter_libs
flutter pub add path_provider
ついでに必要なほかのライブラリも入れましょう。
flutter pub add -d build_runner
flutter pub add -d hive_generator

main.dartで初期化して

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dir = await getApplicationDocumentsDirectory();
  Hive.init(dir.path);
  runApp(const MyApp());
}

ウィジェット内で下のように使うことで、

import 'package:flutter/material.dart';
import 'package:hive/hive.dart';

class SubScreen extends StatefulWidget {
  const SubScreen({super.key});
  @override
  State<SubScreen> createState() => _SubScreenState();
}

class _SubScreenState extends State<SubScreen> {
  int _counter = 0;
  Box<int>? box;
  @override
  void initState() {
    super.initState();
    Future(() async {
      box = await Hive.openBox<int>("sub_counter");//asyncで読み込む
      setState(() {
        _counter = box!.get("count", defaultValue: 0)!;//数値セット
      });
    });
  }

  void _incrementCounter() {
    setState(() {
      _counter++;
      box!.put("count", _counter);//変更後の値を永続化する
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text("サブ画面"),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'This is SubPage',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

こんな感じにデータの永続化ができます。
ライブラリを入れたらさっそく作っていきましょう。
作る画面は以下の4つです

  • タイトル画面 (/title)
  • コントローラー画面 (/controller)
  • パレット選択画面 (/pallet)
  • パレット作成・編集画面 (/pallet/edit)

前編では前者2つを作っていきます。

タイトル画面を作る

まずはタイトル画面を作っていきます。
とはいっても文字と画面遷移用のボタンのみで構成されるシンプルな画面です。

コードは以下のような感じです。

lib/screens/title/title_screen.dart

import 'package:flutter/material.dart';

class TitleScreen extends StatelessWidget {//タイトル画面
  const TitleScreen({super.key});
  void _changescreen(BuildContext context) {
    Navigator.of(context).pushNamed("/controller"); //コントローラー画面に移動する
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text("タイトル画面"),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text("OSC表情コントローラー", style: TextStyle(fontSize: 32)),
            FilledButton(
                onPressed: () => _changescreen(context),//押下時画面遷移
                child:
                    const Text("Screen Change", style: TextStyle(fontSize: 32)))
          ],
        ),
      ),
    );
  }
}

パレット用のクラスを用意する

次に今後の実装で必要になるパレット用のクラスを用意します。
各ジェスチャーごとの表情の名前を保存するためのクラスです。

lib/type/pallet.dart

import 'package:hive/hive.dart';
part 'pallet.g.dart';

//表情名管理用クラス(typeIdはプロジェクト内で一意である必要がある)
@HiveType(typeId: 1)
class Pallet extends HiveObject {
  Pallet(
      {required this.leftidle,
      required this.leftfist,
      required this.leftopen,
      required this.leftpoint,
      required this.leftvictory,
      required this.leftrocknroll,
      required this.leftgun,
      required this.leftthumbsup,
      required this.rightidle,
      required this.rightfist,
      required this.rightopen,
      required this.rightpoint,
      required this.rightvictory,
      required this.rightrocknroll,
      required this.rightgun,
      required this.rightthumbsup});

  //HiveFieldのIndexはクラス内で一意でなければならない
  @HiveField(0)
  String leftidle;
  @HiveField(1)
  String leftfist;
  @HiveField(2)
  String leftopen;
  @HiveField(3)
  String leftpoint;
  @HiveField(4)
  String leftvictory;
  @HiveField(5)
  String leftrocknroll;
  @HiveField(6)
  String leftgun;
  @HiveField(7)
  String leftthumbsup;
  @HiveField(8)
  String rightidle;
  @HiveField(9)
  String rightfist;
  @HiveField(10)
  String rightopen;
  @HiveField(11)
  String rightpoint;
  @HiveField(12)
  String rightvictory;
  @HiveField(13)
  String rightrocknroll;
  @HiveField(14)
  String rightgun;
  @HiveField(15)
  String rightthumbsup;
}

クラスを作成したら、Hiveで非プリミティブオブジェクトを使うのに必要なクラスであるアダプターを生成します。

dart run build_runner build

を実行することでアダプターが生成されます。

lib/type/pallet.g.dart

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'pallet.dart';

// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************

class PalletAdapter extends TypeAdapter<Pallet> {
  @override
  final int typeId = 1;

  @override
  Pallet read(BinaryReader reader) {
    final numOfFields = reader.readByte();
    final fields = <int, dynamic>{
      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
    };
    return Pallet(
      leftidle: fields[0] as String,
      leftfist: fields[1] as String,
      leftopen: fields[2] as String,
      leftpoint: fields[3] as String,
      leftvictory: fields[4] as String,
      leftrocknroll: fields[5] as String,
      leftgun: fields[6] as String,
      leftthumbsup: fields[7] as String,
      rightidle: fields[8] as String,
      rightfist: fields[9] as String,
      rightopen: fields[10] as String,
      rightpoint: fields[11] as String,
      rightvictory: fields[12] as String,
      rightrocknroll: fields[13] as String,
      rightgun: fields[14] as String,
      rightthumbsup: fields[15] as String,
    );
  }

  @override
  void write(BinaryWriter writer, Pallet obj) {
    writer
      ..writeByte(16)
      ..writeByte(0)
      ..write(obj.leftidle)
      ..writeByte(1)
      ..write(obj.leftfist)
      ..writeByte(2)
      ..write(obj.leftopen)
      ..writeByte(3)
      ..write(obj.leftpoint)
      ..writeByte(4)
      ..write(obj.leftvictory)
      ..writeByte(5)
      ..write(obj.leftrocknroll)
      ..writeByte(6)
      ..write(obj.leftgun)
      ..writeByte(7)
      ..write(obj.leftthumbsup)
      ..writeByte(8)
      ..write(obj.rightidle)
      ..writeByte(9)
      ..write(obj.rightfist)
      ..writeByte(10)
      ..write(obj.rightopen)
      ..writeByte(11)
      ..write(obj.rightpoint)
      ..writeByte(12)
      ..write(obj.rightvictory)
      ..writeByte(13)
      ..write(obj.rightrocknroll)
      ..writeByte(14)
      ..write(obj.rightgun)
      ..writeByte(15)
      ..write(obj.rightthumbsup);
  }

  @override
  int get hashCode => typeId.hashCode;

  @override
  bool operator ==(Object other) =>
      identical(this, other) ||
      other is PalletAdapter &&
          runtimeType == other.runtimeType &&
          typeId == other.typeId;
}

コントローラー画面を作る

早速中核である表情をコントロールする画面を作っていきます。
表情変更ボタンは16個配置するのでコンポーネント化しておきましょう。

lib/screens/controller/component/gesture_button.dart

import 'package:flutter/material.dart';

class GestureButton extends StatelessWidget {
  const GestureButton(
      {super.key,
      required this.index,
      required this.direction,
      required this.defaultname,
      required this.send,
      this.namelabel});
  final String defaultname;
  final String? namelabel;
  final String direction;
  final int index;
  final void Function(String dir, int index) send;
  @override
  Widget build(BuildContext context) {
    return FilledButton(
        onPressed: () => send(direction, index),
        child: Text(namelabel ?? defaultname));
  }
}

渡された引数に応じてラベルや押されたときに渡す引数の内容が変わるシンプル(?)なボタンとなっております。

lib/screens/controller/controller_screen.dart

import 'package:flutter/material.dart';
import 'package:osc_facial_controller/screens/controller/component/gesture_button.dart';
import 'package:osc_facial_controller/type/pallet.dart';
import 'package:osc/osc.dart';
import 'dart:io';

class ControllerScreen extends StatefulWidget {
  final Pallet? pallet;
  const ControllerScreen({super.key, this.pallet});
  @override
  State<ControllerScreen> createState() => _ControllerScreenState();
}

class _ControllerScreenState extends State<ControllerScreen> {
  final int port = 9000;
  String _ip = "127.0.0.1";
  Pallet? pallet;
  @override
  void initState() {
    pallet = widget.pallet;
    super.initState();
  }

  void send(String dir, int index) {
    //OSCメッセージをPCに送る
    final destination = InternetAddress.tryParse(_ip); //IPアドレス
    if (destination != null) {
      //正常にパースできたなら
      final address = "/avatar/parameters/G$dir"; //パス
      final message = OSCMessage(address, arguments: [index]);

      RawDatagramSocket.bind(InternetAddress.anyIPv4, 0).then((socket) {
        final bytes = message.toBytes();
        socket.send(bytes, destination, port);
        socket.close();
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text("表情コントローラー"),
      ),
      body: Center(
          child: Column(children: [
        TextField(
          onChanged: (str) => setState(() {
            _ip = str;
          }),
          decoration: const InputDecoration(
            border: OutlineInputBorder(),
            hintText: 'PCのIPアドレスを入力してください',
          ),
        ),
        GridView.count(
          shrinkWrap: true, //trueにしないとエラーが出る
          crossAxisCount: 4, //4個で1行
          children: <Widget>[
            GestureButton(
                index: 1,
                namelabel: pallet?.leftfist,
                defaultname: "Left Fist",
                direction: "L",
                send: send),
            GestureButton(
                index: 0,
                namelabel: pallet?.leftidle,
                defaultname: "Left Idle",
                direction: "L",
                send: send),
            GestureButton(
                index: 0,
                namelabel: pallet?.rightidle,
                defaultname: "Right Idle",
                direction: "R",
                send: send),
            GestureButton(
                index: 1,
                namelabel: pallet?.rightfist,
                defaultname: "Right Fist",
                direction: "R",
                send: send),
            GestureButton(
                index: 3,
                namelabel: pallet?.leftpoint,
                defaultname: "Left Point",
                direction: "L",
                send: send),
            GestureButton(
                index: 2,
                namelabel: pallet?.leftopen,
                defaultname: "Left Open",
                direction: "L",
                send: send),
            GestureButton(
                index: 2,
                namelabel: pallet?.rightopen,
                defaultname: "Right Open",
                direction: "R",
                send: send),
            GestureButton(
                index: 3,
                namelabel: pallet?.rightpoint,
                defaultname: "Right Point",
                direction: "R",
                send: send),
            GestureButton(
                index: 5,
                namelabel: pallet?.leftrocknroll,
                defaultname: "Left Rock 'N' Roll",
                direction: "L",
                send: send),
            GestureButton(
                index: 4,
                namelabel: pallet?.leftvictory,
                defaultname: "Left Victory",
                direction: "L",
                send: send),
            GestureButton(
                index: 4,
                namelabel: pallet?.rightvictory,
                defaultname: "Right Victory",
                direction: "R",
                send: send),
            GestureButton(
                index: 5,
                namelabel: pallet?.rightrocknroll,
                defaultname: "Right Rock 'N' Roll",
                direction: "R",
                send: send),
            GestureButton(
                index: 7,
                namelabel: pallet?.leftthumbsup,
                defaultname: "Left ThumbsUp",
                direction: "L",
                send: send),
            GestureButton(
                index: 6,
                namelabel: pallet?.leftgun,
                defaultname: "Left Gun",
                direction: "L",
                send: send),
            GestureButton(
                index: 6,
                namelabel: pallet?.rightgun,
                defaultname: "Right Gun",
                direction: "R",
                send: send),
            GestureButton(
                index: 7,
                namelabel: pallet?.rightthumbsup,
                defaultname: "Right ThumbsUp",
                direction: "R",
                send: send),
          ],
        ),
      ])),
    );
  }
}


IPアドレス入力用のテキストフィールドと16個のボタンが並んだシンプルな画面です。
OSC送信用の関数と各種引数をボタンに渡しています。
↑目次に戻る
最後にmainに画面のルーティングなどを登録すれば完成です!

lib/main.dart

import 'package:flutter/material.dart';
import 'package:osc_facial_controller/screens/controller/controller_screen.dart';
import 'package:osc_facial_controller/type/pallet.dart';
import 'package:osc_facial_controller/screens/title/title_screen.dart';
import 'package:path_provider/path_provider.dart';
import 'package:hive/hive.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  final dir = await getApplicationDocumentsDirectory();
  Hive.init(dir.path);
  Hive.registerAdapter(PalletAdapter());
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const TitleScreen(),
      routes: <String, WidgetBuilder>{
        '/title': (BuildContext context) => const TitleScreen(),
        '/controller': (BuildContext context) => const ControllerScreen()
      },
    );
  }
}

🔗Unity側で行う準備

Flutter側での実装が完了したので今度はUnity側で表情制御のための準備を行います。
今回利用するアバターはこちらのハオランというアバター。なんと法人利用可能な無料アバターです。

【オリジナル3Dモデル】-ハオラン-HAOLAN
いつもの様に導入して、アップロードする前にひと準備、
FXレイヤーのコントローラーのGestureLeftとGestureRightというパラメーターをそれぞれGL,GRにリネーム。
Assets/HAOLAN/3.0_Animation/EXmenu/HAOLANEXParameters.assetにGLとGRをint型で追加します。
↑目次に戻る

🔗完成品

アップロードしてアバターを変更して、起動したエミュレータから操作すれば…


(プライバシー保護のため、IP入力欄を消し、IPをハードコードにしています。)
無事表情を変更できました。
↑目次に戻る

🔗あとがき

表情の変更はできましたが、まだ表情を覚えられない問題は解決していません。
次回、後編でお会いしましょう。
↑目次に戻る


株式会社ECNはPHP、JavaScriptを中心にお客様のご要望に合わせたwebサービス、システム開発を承っております。
ビジネスの最初から最後までサポートを行い
お客様のイメージに合わせたWebサービス、システム開発、デザインを行います。


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。