以前、Sony製モーションキャプチャ―のmocopiを購入したのですが、動画制作や配信などをやっていないので、いまだ活用できていない状況です。何かしらやらないと意味が無いので、普段触ることが多いWebブラウザで何かできないかを考えてみました。 何を作るにしてもmocopiのデータをブラウザで読み取る必要があるので、今回は表示した3Dモデルを動かすところまでをやってみます。
モチベーション
- mocopiを使ったWebアプリを考えたい
- ブラウザの機能を調べてできることを増やしたい
実装方針
重要な点を先に書きますが、ブラウザ単体でmocopiデータを制御することはできませんでした。mocopi自体はBluetooth接続なので、Web Bluetooth APIでゴニョゴニョすればどうにかなるかもと思ったのですが、ドライバの問題なのかペアリングすらできませんでした。スマホでもアプリ起動前はペアリングできずでした。
※そもそもセンサーだけ繋いでも全身の動きを推定できないことには後から気づきました。
データをブラウザに送るためmocopiアプリのデータ送信機能を使うのですが、用意されている通信方法(UDP)はブラウザで直接扱えないためWebSocketを経由します。
UDP受信 + WebSocket送信用サーバを立てます。試しなのでポート番号はとりあえず決め打ちです。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
const { networkInterfaces } = require('os'); const { createSocket } = require('dgram'); const { Server } = require('ws'); const PORT_WEB_SOCKET = 5000; const PORT_UDP = 12351; const nif = networkInterfaces()['en0'].find((nif) => nif.family.toLocaleLowerCase() === 'ipv4'); const udp4 = createSocket('udp4'); udp4.on('listening', () => console.log(`mocopit${nif.address}:${PORT_UDP}`, 'rn', `webt${nif.address}:${PORT_WEB_SOCKET}`)); udp4.bind(PORT_UDP, nif.address); const webSocketServer = new Server({ host: nif.address, port: PORT_WEB_SOCKET }); webSocketServer.on('connection', (ws) => { console.log('connected'); udp4.on('message', ( data ) => { // mocopi(UDP)受信 → WebSocket送信 ws.send(data); }); }); |
結果
デモ動画に使用したモデルの利用規約上、動画を公開できないのですが動きを連動できました。実装内容はこちらのファイルを参照ください。
cliでディレクトリに移動したら以下、順番に実行します
1 2 |
# 必要なパッケージをインストール npm install |
1 2 |
# UDP -> WebSocket 転送サーバ起動 node ./fowarding.js & |
1 2 |
# 動作確認アプリの立ち上げ npx parcel src/index.html |
データの解析
まずはmocopiから送られてくるデータを解析します。データを再利用するため、受信した順に1000回分くらいのデータをファイルに保存してみました。 最初に1821byteのデータが来て、その後1575byteのデータが続きます。
その後も時々1821byteのデータを受信していましたがほとんどが1575byteのデータでした。 内容を見ると似たようなデータが入っていましたが1575byteの方が若干項目が少なかったのでそちらを使って実装していきます(UDP通信なので取得順は関係ないデータになっている想定です)。 冒頭の#head,ftyp,sony motion~~~はおそらくフォーマット宣言の部分なので一旦飛ばします。
続きを見るとbnid、tranという文字が度々も登場しているのがわかります。数は27です。mocopiの技術仕様によるとスケルトンのジョイント数が27なので、各ジョイントごとのデータを送っているのだと想像できます。
.btdt(=2E 00 00 00 62 74 64 74)ごとに区切りつつ、制御文字なども考慮して見てみると、bnidの後の2byte、tranの後の28byteが実際のデータっぽく見えます。
パースして必要そうなデータを抽出
.btdtごとに分割して、さらに必要そうなデータを抽出します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 |
type TranData = { rotation: number[]; position: number[]; } export type MocopiData = { id: number; tran: TranData; } export default class Parser { parse(buffer: ArrayBuffer): MocopiData[]|null { // 1575byteのデータ以外は無視する if (buffer.byteLength !== 1575) return null; const bufferString = this.bufferToString(buffer); const data: ArrayBuffer[] = []; const indices: number[] = []; let i = -1; while (!!~(i = bufferString.indexOf(`.${'x00x00x00'}btdt`, i + 1))) { // .btdtが出現する位置を取得 indices.push(i); } indices.forEach((start, index, array) => { // 取得した位置ごとに分割 const end = array?.[index + 1] ?? undefined; data.push(buffer.slice(start, end)); }); const result: (MocopiData | undefined)[] = data.map((value, index) => { // 分割したデータをパース const id = this.parseBnid(value); const tran = this.parseTran(value); if (id === null || tran === null) return; return { id, tran } }); return result.filter(<T = MocopiData>(v?: T): v is T => !!v); } private parseBnid(buffer: ArrayBuffer): number|null { const data = this.sliceBuffer(buffer, 'bnid', 2); return !data ? null : new DataView(data).getUint16(0, true); } private parseTran(buffer: ArrayBuffer): TranData|null { const byte = 28; const data = this.sliceBuffer(buffer, 'tran', byte); if (!data) return null; const coordinate = [-1, 1, -1, 1]; const result = Array.from({length: byte / 4}) .map((_, i) => { const pos = i * 4; return data.slice(pos, pos + 4); }) .map(buf => (new DataView(buf)).getFloat32(0, true)); return { rotation: result.slice(0, 4).map((v, i) => v * coordinate[i]), position: result.slice(4).map((v, i) => v * coordinate[i]), } } private sliceBuffer(buffer: ArrayBuffer, key: string, byte: number = 1): ArrayBuffer|null { const bufferString = this.bufferToString(buffer); if (!~bufferString.indexOf(key)) return null; const pos = bufferString.indexOf(key) + key.length; return buffer.slice(pos, pos + byte); } private bufferToString(buffer: ArrayBuffer): string { const bufferView = new Uint8Array(buffer); return String.fromCharCode.apply(null, Array.from(bufferView)); } } |
bnidデータをパース
bnidは、mocopiのスケルトン定義のIndexです。2byte切り出して数値に変換します。
1 2 3 4 |
private parseBnid(buffer: ArrayBuffer): number|null { const data = this.sliceBuffer(buffer, 'bnid', 2); return !data ? null : new DataView(data).getUint16(0, true); } |
tranデータをパース
tranは、回転情報と位置情報がセットされています。28byte切り出し、4byteごとに分割した後、それぞれを32bit Floatに変換します(下記コードの6~12行目)。
分割したデータの前半4つは回転情報、後半3つが位置情報になっていました。
実際に動かした後でわかったのですが座標系がWebGLと違うようなので、一部の値を反転しています。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
private parseTran(buffer: ArrayBuffer): TranData|null { const byte = 28; const data = this.sliceBuffer(buffer, 'tran', byte); if (!data) return null; const coordinate = [-1, 1, -1, 1]; const result = Array.from({length: byte / 4}) .map((_, i) => { const pos = i * 4; return data.slice(pos, pos + 4); }) .map(buf => (new DataView(buf)).getFloat32(0, true)); return { rotation: result.slice(0, 4).map((v, i) => v * coordinate[i]), position: result.slice(4).map((v, i) => v * coordinate[i]), } } |
mocopiデータを3Dモデルにセットする
今回、VRMフォーマットで作られた3Dモデルを使います。VRMは人型の3Dアバターに特化したファイルフォーマットです。mocopiのオリジナルアバターもVRMで配布されていたので相性が良いのだろうと考えました。
bnidとVRMのボーン名をマッピングします。対応させる情報は下記のページを参考にしました。
mocopi
https://www.sony.net/Products/mocopi-dev/jp/documents/Home/TechSpec.html
VRM
https://github.com/vrm-c/vrm-specification/blob/master/specification/VRMC_vrm-1.0/humanoid.ja.md#%E3%83%92%E3%83%A5%E3%83%BC%E3%83%9E%E3%83%8E%E3%82%A4%E3%83%89%E3%83%9C%E3%83%BC%E3%83%B3%E3%81%AE%E4%B8%80%E8%A6%A7
この情報をもとに、mocopiのデータをVRMにセットする処理を実装します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 |
import * as THREE from 'three'; import { VRM } from '@pixiv/three-vrm'; import type { VRMHumanBoneName } from '@pixiv/three-vrm'; import type { MocopiData } from './parser'; /** * mocopiのデータをVRMのHumanoidに */ export default class Adapter { constructor() {} vrm: VRM|null = null; boneNameMap = new Map<number, VRMHumanBoneName|''>([ [0, 'hips'], [1, ''], // torso_1 [2, 'spine'], [3, ''], // torso_3 [4, 'chest'], [5, ''], // torso_5 [6, 'upperChest'], [7, ''], // torso_7 [8, 'neck'], [9, ''], // neck_2 [10, 'head'], [11, 'leftShoulder'], [12, 'leftUpperArm'], [13, 'leftLowerArm'], [14, 'leftHand'], [15, 'rightShoulder'], [16, 'rightUpperArm'], [17, 'rightLowerArm'], [18, 'rightHand'], [19, 'leftUpperLeg'], [20, 'leftLowerLeg'], [21, 'leftFoot'], [22, 'leftToes'], [23, 'rightUpperLeg'], [24, 'rightLowerLeg'], [25, 'rightFoot'], [26, 'rightToes'], ]); /** * VRMモデルをセットする * @param vrm */ setVrm(vrm: VRM) { if (this.vrm) { this.vrm.scene?.removeFromParent(); } this.vrm = vrm; } setMocopiData(data: MocopiData[]) { if (!this.vrm) return; data.forEach(value => { const nodeName = this.boneNameMap.get(value.id); if (!nodeName) return; const node = this.vrm?.humanoid.getRawBoneNode(nodeName); if (value.id === 0) { // rootのみポジションを変更 const vector = new THREE.Vector3(...value.tran.position); node?.position.copy(vector); } const quaternion = new THREE.Quaternion(...value.tran.rotation); node?.quaternion.copy(quaternion); }); } } |
数が一致しないので一部が空白になっていますが、だいたい割り当てられていると思います。
setMocopiDataを実行するとVRMの各ボーンに回転情報、ルートのボーンには位置情報をセットします。
位置情報をルート以外にもセットしてしまうと3Dモデルが想定通りに表現されません(関節の位置がおかしくなりました)。
WebSocketで送られてきたデータをパース + 3Dモデルにセット
見出し通りに処理します。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import Adapter from "./adapter"; import Parser from "./parser"; type Props = { adapter: Adapter; parser: Parser; ip?: string; port?: string; secure?: boolean; } export default async function socket(props: Props): Promise<void> { const {adapter, parser, ip = '127.0.0.1', port = '5000', secure = false} = props; const scheme = secure ? 'wss' : 'ws'; const ws = new WebSocket(`${scheme}://${ip}:${port}`); ws.addEventListener('message', (msg) => { // 受信したデータをパースして3dモデルにセットする msg.data.arrayBuffer().then((buffer) => { const mocopiData = parser.parse(buffer); if (mocopiData) adapter.setMocopiData(mocopiData); }); }); ws.addEventListener('open', (ev) => { // connection start. }); ws.addEventListener('error', () => { throw new Error('接続失敗'); }); } |
課題
- 足元が埋もれてしまうので補正する処理が必要。
- 使う3Dモデルによっては反対に浮かんでしまうかもしれません。
- データ1つあたりのパース時間はおよそ0.7msでしたが、さらに減らせないか検証したい。
- データ転送処理(UDP→WebSocket)の実行環境を準備する手間を解消したい(直接実行できるファイルにしたい)。
- これを使ったサービスのアイディアが浮かばない。
終わりに
今回データのパース処理をクライアント側に書いたのですが、単純な動作だったためか遅延をあまり感じないレベルだったので驚きました。mocopiそのものの活用方法は浮かばなかったものの、ArrayBufferを使ったバイナリデータの操作や、WebSocketの通信など業務では扱うことのなかったブラウザの機能を調べるきっかけになったのは良かったなと思います。