イネ科

すっぺらぴっちょん

UnityでVRMモデルの胸を揺らす知見

通常のVRMモデルを用いるアプリにおいて、このような知見は不要かもしれないが、特定の条件下でVRMモデルの胸を揺らしたいケースは必ず出てくると思う。衝突法がVRM憑依ちゃんの開発で使っている手法。囲い型は組み込み検討中。

必読

VRMSpringBone - VRM

手法

VRアプリで胸を触る場合

VRアプリの場合は、手に追従するGameObjectに対しVRMSpringBoneColliderGroup.csをアタッチしてパラメータを調整、胸を揺らしたいVRMモデルのsecondaryにあるVRMSpringBone(胸のRoot Bonesが設定された箇所)のCollider Groupsに手のGameObjectを指定。髪も触りたい場合は全てにColliderを設定すれば良い。半径を大きくしすぎると現実離れした胸揺れになるので、0.05~0.1あたりが最適と思われる。

f:id:sesleria:20190508143139p:plain:w400

f:id:sesleria:20190508142143g:plain:w200f:id:sesleria:20190508142159g:plain:w200

オブジェクトの衝突

自分で触らずに、Animation等と同期処理させたいケースで用いる。Animationだけでは揺れをダイナミックに表現できない為、VRMSpringBoneColliderGroupをオブジェクトに設定し、胸に衝突させるという手法。欠点として、Animationにより対象の胸の位置が変化した場合、都度オブジェクトの位置や、衝突させる角度を変更する必要がある。

参考画像(センシティブツイート) :
せすれりあ on Twitter: "私が考えたVRMの乳揺らしシステム(ある意味物理)… "

   //コルーチンでこういうのを回すだけ
    for(int j=0; j<10; j++){
        Shake.transform.position -= MainCharactor.Player.transform.forward / 100; 
        yield return new WaitForSeconds(0.01f);
    }

囲い型

胸の周りにVRMSpringBoneColliderGroupを設定したオブジェクトを配置、状況によりRadiusをコルーチンで調整し、胸を揺らすという手法。揺れを細かく制御でき、親子関係を設定することで体に追従するので、位置を考慮する必要はない。しかし、VRMモデルの胸サイズや体格によっては個別に位置を調整する必要がある為、動的に読み込んだVRMに対しては使いにくい弱点も。VRoidの場合は正確なポジションを設定しやすい(詳細は後述)。UpperChestは設定されていないモデルが多いので、そういった場合は他のボーンから位置を取得して補正しなければならない。

f:id:sesleria:20190508143120g:plain:w300

f:id:sesleria:20190508143125g:plain:w220f:id:sesleria:20190508143129g:plain:w200

左右から衝撃を与えるケースにおいて、Radius差による揺れ表現の違い
f:id:sesleria:20190508143131g:plain:w200f:id:sesleria:20190508143134g:plain:w200

事前準備

Resourcesフォルダに以下のPrefabを作成。
f:id:sesleria:20190508144245p:plain:w400

コード

//VRMモデルにアタッチする
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using VRM;

public class ShakeBust : MonoBehaviour
{
    [SerializeField]
    public float defineTransition = 0.01f;
    [SerializeField]
    public float defineSize = 0.1f;

    private Vector3[] colliderPosition = new Vector3[9];
    private GameObject[] shakingCollider = new GameObject[9];
    private VRMSpringBoneColliderGroup[] springBone = new VRMSpringBoneColliderGroup[9];

    // Start is called before the first frame update
    void Start()
    {
        Animator anime = this.GetComponent<Animator>();
        Transform leftUpperArm = anime.GetBoneTransform(HumanBodyBones.LeftUpperArm);
        Transform rightUpperArm = anime.GetBoneTransform(HumanBodyBones.RightUpperArm);
        Transform parentChest = anime.GetBoneTransform(HumanBodyBones.UpperChest);

        colliderPosition[0] = new Vector3(parentChest.position.x, parentChest.position.y - 0.03f, parentChest.position.z + 0.10f);
        colliderPosition[1] = new Vector3(parentChest.position.x + leftUpperArm.localPosition.x / 1.05f, parentChest.position.y - 0.03f, parentChest.position.z + 0.15f);
        colliderPosition[2] = new Vector3(parentChest.position.x + leftUpperArm.localPosition.x / 1.05f, parentChest.position.y + 0.03f, parentChest.position.z + 0.11f);
        colliderPosition[3] = new Vector3(parentChest.position.x + leftUpperArm.localPosition.x / 1.05f, parentChest.position.y - 0.09f, parentChest.position.z + 0.11f);
        colliderPosition[4] = new Vector3(parentChest.position.x + leftUpperArm.localPosition.x - 0.05f, parentChest.position.y - 0.03f, parentChest.position.z + 0.09f);
        colliderPosition[5] = new Vector3(parentChest.position.x + rightUpperArm.localPosition.x / 1.05f, parentChest.position.y - 0.03f, parentChest.position.z + 0.15f);
        colliderPosition[6] = new Vector3(parentChest.position.x + rightUpperArm.localPosition.x / 1.05f, parentChest.position.y + 0.03f, parentChest.position.z + 0.11f);
        colliderPosition[7] = new Vector3(parentChest.position.x + rightUpperArm.localPosition.x / 1.05f, parentChest.position.y - 0.09f, parentChest.position.z + 0.11f);
        colliderPosition[8] = new Vector3(parentChest.position.x + rightUpperArm.localPosition.x + 0.05f, parentChest.position.y - 0.03f, parentChest.position.z + 0.09f);

        GameObject shakinbBall = (GameObject)Resources.Load("ShakingBall");
        
        for(int i = 0; i <= 8; i++){
            shakingCollider[i] = Instantiate(shakinbBall, colliderPosition[i], Quaternion.identity);
            shakingCollider[i].name = "ShakingBall_" + i;
            shakingCollider[i].transform.parent = parentChest.transform;
            springBone[i] = shakingCollider[i].GetComponent<VRMSpringBoneColliderGroup>();
        }

        GameObject secondary = this.transform.Find("secondary").gameObject;
        VRMSpringBone[] Bones = secondary.GetComponents<VRMSpringBone>();

        foreach (VRMSpringBone Bone in Bones) {
            if(Bone.ColliderGroups == null){
                Bone.ColliderGroups = new VRMSpringBoneColliderGroup[9];
                for(int i = 0; i <= 8; i++){
                    Bone.ColliderGroups[i] = springBone[i];
                }
            }else{
                Array.Resize(ref Bone.ColliderGroups , Bone.ColliderGroups.Length + 9);
                for(int i = 1; i <= 9; i++){
                    Bone.ColliderGroups[Bone.ColliderGroups.Length - i] = springBone[i - 1];
                }
            }
        }

    }

    // Update is called once per frame
    void Update()
    {
        if(Input.GetKeyDown(KeyCode.Alpha1)){
            StartCoroutine(ShakeBoobs(0, defineTransition, defineSize));
        }
        if(Input.GetKeyDown(KeyCode.Alpha2)){
            StartCoroutine(ShakeBoobs(1, defineTransition, defineSize));
        }
        if(Input.GetKeyDown(KeyCode.Alpha3)){
            StartCoroutine(ShakeBoobs(2, defineTransition, defineSize));
        }
        if(Input.GetKeyDown(KeyCode.Alpha4)){
            StartCoroutine(ShakeBoobs(3, defineTransition, defineSize));
        }
        if(Input.GetKeyDown(KeyCode.Alpha5)){
            StartCoroutine(ShakeBoobs(4, defineTransition, defineSize));
        }
        if(Input.GetKeyDown(KeyCode.Alpha6)){
            StartCoroutine(ShakeBoobs(5, defineTransition, defineSize));
        }
        if(Input.GetKeyDown(KeyCode.Alpha7)){
            StartCoroutine(ShakeBoobs(6, defineTransition, defineSize));
        }
        if(Input.GetKeyDown(KeyCode.Alpha8)){
            StartCoroutine(ShakeBoobs(7, defineTransition, defineSize));
        }
        if(Input.GetKeyDown(KeyCode.Alpha9)){
            StartCoroutine(ShakeBoobs(8, defineTransition, defineSize));
        }
    }

    private IEnumerator ShakeBoobs(int colliderNo, float transition, float size){
        for(int i = 10; i > 0; i--){
            springBone[colliderNo].Colliders[0].Radius = size / i;
            yield return new WaitForSeconds(transition);
        }
        springBone[colliderNo].Colliders[0].Radius = 0;
    }
}

以下の部分において全てのVRMSpringBoneにColliderを設定しているのは、胸がどこに設定されているのか判別するのが困難な為。コメント等で判別可能なケースもある。その為、Radiusを極端に大きな値にしてしまうと、髪の毛等と衝突してしまうので注意が必要。

        GameObject secondary = this.transform.Find("secondary").gameObject;
        VRMSpringBone[] Bones = secondary.GetComponents<VRMSpringBone>();

        foreach (VRMSpringBone Bone in Bones) {
            if(Bone.ColliderGroups == null){
                Bone.ColliderGroups = new VRMSpringBoneColliderGroup[9];
                for(int i = 0; i <= 8; i++){
                    Bone.ColliderGroups[i] = springBone[i];
                }
            }else{
                Array.Resize(ref Bone.ColliderGroups , Bone.ColliderGroups.Length + 9);
                for(int i = 1; i <= 9; i++){
                    Bone.ColliderGroups[Bone.ColliderGroups.Length - i] = springBone[i - 1];
                }
            }
        }

特殊ケース(VRoidモデル)

VRoidモデル限定ではあるが、transform.find等で以下オブジェクトのpositionを取得してやると、確実に胸の付け根(Bust1)や、乳輪の位置(Bust2)を取得出来る。このパラメータを元にすれば、囲い型でも、ほぼ正確なポジションにオブジェクトを配置可能。

J_Sec_L_Bust1 J_Sec_L_Bust2 J_Sec_R_Bust1 J_Sec_R_Bust2

いつもの