見出し画像

cluster の OSC 受信機能を使ってアバターを自在にポージングさせる

メタバースプラットフォーム cluster に実装された OSC 受信機能を用いて、アバターを VMC Protocol に基づいて自在にポージングさせる


はじめに

こんにちは。いつも cluster で遊んでいるやまちゃんです。

昨日 (2025/02/10) の cluster のアップデートにて、いよいよ OSC Receiver が使用できるようになりました。
スクリプトで任意のアドレスに対するメッセージを処理できるという、手間は掛かるものの何でもできそうな仕様でかなり遊べそうです。

そこで、今回は OSC 上で構築されているプロトコルである VMC Protocol の受信仕様(VMC Protocol Marionette)に基づいたアバターのポーズ制御スクリプトを実装してみました。

お試しワールド作りました

ということで、まずはお試しワールドを作りましたので遊びに来てみてね!
(入室する前に cluster クライアントの OSC 受信機能を有効にしておいてください)

VMC Protocol の送信は対応ソフトであればなんでも行けると思いますが、当方の手元では TDPT v 0.6 を使用しました。
「Send & Receive」→「Send VMC Protocol」 の送信ポートを 9000 に変更して送出してください。

スクリプト全文公開

本検証で使用したスクリプトの内容を、参考までに掲載します。

スクリプトは、ClusterScript と Player Script に分かれます。
前者は Player Script の各ユーザーへの割り当てのみを行い、後者がそれ以外の全ての処理を行います。
それぞれを個別に *.js ファイルに保存し、Unity (CCK) 上で空のゲームオブジェクトを作成、Player Script コンポーネントを追加して各スクリプトファイルを下図のように Source Code Asset に割り当てれば機能します。

ClusterScript は "Scriptable Item" に、Player Script は同名のコンポーネントに

本スクリプトの ClusterScript ではスペースに入室した人全員に自動的に Player Script を割り当てるようにしていますが、これを例えばアイテムにインタラクトしたら割り当てるように書き変えても Player Script は無変更で動作すると思われます。

ClusterScript

$.onStart(() => {
    $.log("onStart()");

    $.state.players = [];
});

$.onUpdate((deltaTime) => {
    // known players (Player Script has been set)
    let players = $.state.players;
    // current players (existing on the space just now)
    let cur_players = $.getPlayersNear(new Vector3(0, 0, 0), Infinity);

    // pick up players who are not set Player Script
    const new_players = cur_players.filter(cur_p => {
        return !players.some(p => p.id === cur_p.id);
    });

    // set Player Script
    for (const player of new_players) {
        // dirty countermeasure: sometimes player handle returns null data
        if (!player || !player.userId) {
            continue;
        }

        $.setPlayerScript(player);
        players.push(player);

        const user_name = player.userDisplayName;
        const user_id = player.userId;
        $.log("set Player Script to " + user_name +
              " (@" + user_id + ")");
    }

    // filtering non-existent players
    players = players.filter(p => p.exists());

    $.state.players = players;
});

Player Script

const bone_hierarchy = {
  "Hips": {
    "Spine": {
      "Chest": {
        "UpperChest": {
          "Neck": {
            "Head": {
              "LeftEye": {},
              "RightEye": {},
              "Jaw": {} // is it right position?
            }
          },
          "LeftShoulder": {
            "LeftUpperArm": {
              "LeftLowerArm": {
                "LeftHand": {
                  "LeftThumbProximal": {
                    "LeftThumbIntermediate": {
                      "LeftThumbDistal": {}
                    }
                  },
                  "LeftIndexProximal": {
                    "LeftIndexIntermediate": {
                      "LeftIndexDistal": {}
                    }
                  },
                  "LeftMiddleProximal": {
                    "LeftMiddleIntermediate": {
                      "LeftMiddleDistal": {}
                    }
                  },
                  "LeftRingProximal": {
                    "LeftRingIntermediate": {
                      "LeftRingDistal": {}
                    }
                  },
                  "LeftLittleProximal": {
                    "LeftLittleIntermediate": {
                      "LeftLittleDistal": {}
                    }
                  }
                }
              }
            }
          },
          "RightShoulder": {
            "RightUpperArm": {
              "RightLowerArm": {
                "RightHand": {
                  "RightThumbProximal": {
                    "RightThumbIntermediate": {
                      "RightThumbDistal": {}
                    }
                  },
                  "RightIndexProximal": {
                    "RightIndexIntermediate": {
                      "RightIndexDistal": {}
                    }
                  },
                  "RightMiddleProximal": {
                    "RightMiddleIntermediate": {
                      "RightMiddleDistal": {}
                    }
                  },
                  "RightRingProximal": {
                    "RightRingIntermediate": {
                      "RightRingDistal": {}
                    }
                  },
                  "RightLittleProximal": {
                    "RightLittleIntermediate": {
                      "RightLittleDistal": {}
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "LeftUpperLeg": {
      "LeftLowerLeg": {
        "LeftFoot": {
          "LeftToes": {}
        }
      }
    },
    "RightUpperLeg": {
      "RightLowerLeg": {
        "RightFoot": {
          "RightToes": {}
        }
      }
    }
  }
};

let bone_rotations = {};
let bone_received = {};

function get_bone_rotations_from_current_bone(rotations, cur_rotation, bone_hierarchy) {
    for (const key of Object.keys(bone_hierarchy)) {
        let rotation = cur_rotation.clone();
        if (key in bone_rotations) {
            rotation = rotation.multiply(bone_rotations[key]);
        }
        rotations[key] = rotation;
        // call recursively
        get_bone_rotations_from_current_bone(rotations, rotation, bone_hierarchy[key]);
    }
}

function get_bone_rotations_from_root(avatar_rotation) {
    let rotations = {};

    const init_rotation = avatar_rotation; // initialized as avatar rotation
    get_bone_rotations_from_current_bone(rotations, init_rotation, bone_hierarchy);

    return rotations;
}

function set_humanoid_pose() {
    // do nothing if OSC receiver is disabled
    if (!(_.oscHandle.isReceiveEnabled())) {
        return;
    }

    const avatar_rotation = _.getRotation();
    // _.getRotation() may return null
    if (avatar_rotation) {
        const bone_rotations_from_root = get_bone_rotations_from_root(avatar_rotation);

        const now_ms = Date.now();

        // apply only received bones
        for (const key of Object.keys(bone_rotations)) {
            // ignore bones which is not a member of bone_hierarchy
            if (!(key in bone_rotations_from_root)) {
                continue;
            }
            // ignore too old (over 1 second ago) messages
            if (!(key in bone_received) || (now_ms - bone_received[key] > 1000)) {
                continue;
            }

            const rotation = bone_rotations_from_root[key];
            _.setHumanoidBoneRotationOnFrame(HumanoidBone[key], rotation);
        }
    }
}

function on_receive_vmc_ext_bone_pos(args) {
    if (args.length < 8) {
        _.log("arguments of /VMC/Ext/Bone/Pos is too short" +
              " (8 expected, but " + args.length + ")");
        return;
    }

    const bone_name = args[0].getAsciiString();
    const q_x = args[4].getFloat();
    const q_y = args[5].getFloat();
    const q_z = args[6].getFloat();
    const q_w = args[7].getFloat();

    const rotation = new Quaternion(q_x, q_y, q_z, q_w);
    bone_rotations[bone_name] = rotation;
    bone_received[bone_name] = Date.now();
}

_.oscHandle.onReceive((messages) => {
    for (const msg of messages) {
      if (msg.address === "/VMC/Ext/Bone/Pos") {
          on_receive_vmc_ext_bone_pos(msg.values);
      }
    }
});

_.onFrame((deltaTime) => {
    set_humanoid_pose();
});

やっていることは、ざっくりと

  • ボーン姿勢メッセージ受信時に、on_receive_vmc_ext_bone_pos を呼ぶ

  • 毎フレームの描画時に、set_humanoid_pose を呼ぶ

の 2 点となります。

後者で少し複雑な点は、get_bone_rotations_from_current_bone でしょう。この関数は再帰呼び出しされます。
これは、VMC Protocol におけるボーン姿勢が親ボーンを基準としたローカル座標系なのに対して、PlayerScript におけるボーンの回転の指定はグローバル座標系で行う必要があり、ボーンの親子関係に沿って順に回転を適用していく必要があるためです。スクリプトの冒頭にあり、総行数の半分以上を占める巨大なオブジェクトはボーンの親子関係を定義しています。

【2025-02-15】
Player Script の内容を更新しました。
・ワールド入室後に cluster の OSC 受信設定を変更しても、再入室が不要になりました。
・モーションキャプチャシステム側で VMC Protocl の送信をやめると cluster によるポーズ制御に自動的に戻るようになりました。
・そのほか、動作安定性を向上しました。

まとめ

OSC Receiver 面白いからドンドン遊んでみよう!

いいなと思ったら応援しよう!