イネ科

すっぺらぴっちょん

Unity製のAndroidアプリをリリースしたお話(VRMascot)

はじめに

簡易的なAndroid向けのVRMビューアが欲しかったので、作ったというお話です。 多機能なアプリはいくつかリリースされているのですが、シンプルに見たいだけというニーズを満たせるものは無いように思いました。無いのなら作るしかない。作ればよい。アァァッ。

play.google.com

追記:
2019/5/28 プライバシーポリシーを追加したら停止処置を受けました。異議申し立て中。
2019/5/29 アプリ内リンクのTwitterとBoothが性的コンテンツ扱いされていたようで、却下される。アプリの性的コンテンツを徹底排除して再度リリース。今のところ停止されず。

機能について

思いつき駆動開発なので、最小限で実装。

  • ストレージ内のVRMファイルの読み込み
  • 頭をタッチした時に反応してくれる
  • 胸をタッチした時に反応がある
  • 最初に読み込むモデルはStreamingAssetに配置(Replaceが楽そうという理由。これが後ほどボトルネックに。)

胸をタッチする実装は、今までの開発で得た知見を組み込みたかった為。

タッチ操作の決め方

マウス処理の流用が予想以上にダメすぎたので、Touchを使用して実装。Touch処理完全に理解した。

指1本スワイプ>視点変更
指2本 ピンチインアウト>縮小拡大
指4本 スワイプ>水平垂直移動
指一本タップ>Ray関係

指3本の処理を使っていないのは、スワイプがAndroidスクリーンショット操作と被ってしまうから。

待機アニメーションと表情の制御について

待機アニメーションについては、シンプルに呼吸をしている感じを出すものを作成し、それを適用したのみ。 表情に制御についてはアダルトVRMアプリ向けに書いたコードを流用し、なめらかになるように改善。 少し表情を遷移させてあげるだけでも、ドキドキ感、アナログハック感が出てくる。

VRMのBlendShapeを良い感じに遷移させてみる - イネ科

カメラワークについて

FOV5設定(かわいく見える)のカメラをストレス無く操作できるように、パラメータを調整。拡大の処理についてはモデルに近づきすぎると反対側にカメラが移動してしまうので、移動制限の処理を組み込んで 回避1している。
バストアップや、全体表示時のカメラポジションについては、Headのボーン位置、VRMモデルの身長をメッシュから算出している。

//身長を計算するやつ。コメントアウト部分についてはあえて残している。
    public float GetSkinnedMeshRendererHeight(){
        float maxHeight = 0.0f;
        // var allSkinnedMeshRenderer = mainVRM.GetComponentsInChildren<SkinnedMeshRenderer>(true);
        var allSkinnedMeshRenderer = mainVRM.GetComponentsInChildren<Renderer>(true);
        for (int i = 0; i < allSkinnedMeshRenderer.Length; i++)
        {
            // float height = allSkinnedMeshRenderer[i].bounds.size.y;
            float height = allSkinnedMeshRenderer[i].bounds.center.y + allSkinnedMeshRenderer[i].bounds.extents.y;
            if(height > maxHeight){
                maxHeight = height;
            }
        }
        return maxHeight;
    }

胸のタッチ処理について

2019/5/29 新リリース版では機能を排除(停止対策) f:id:sesleria:20190524020247p:plain:h300f:id:sesleria:20190524020256p:plain:h300

胸の周囲に設置したコライダーへのタッチ判定をScreenPointToRayで取得。タッチした箇所のVRMSpringBoneColliderGroupを調整して、胸を揺らしている。
※以下の記事に書いてある、囲い込み法を組み込み。

UnityでVRMモデルの胸を揺らす知見 - イネ科

頭のタッチ時の処理についてもコライダーを仕込んでいる。細かく制御する場合は、部位ごとにコライダーを仕込んでタグとか名前で判定してあげれば良さそう。

StreamingAssetから読み込む際の遅延対策

起動時にStreamingAssetからVRMファイルを読み込む際、数秒かかってしまう事が判明。以下のようにSliderで作成したプログレスバーを表示することにより、ユーザーに不安を与えないように。読み込みが終わったタイミングでStopCoroutineで止めてあげれば、「案外早かったな?」感も出せて一石二鳥。

//やっつけコルーチン
    IEnumerator ProgressAnime(){
        progressBar.gameObject.SetActive(true);
        float shinchoku = 0.0f;
        while(shinchoku <= 1.0f){
            shinchoku += 0.01f;
            progressBar.value = shinchoku;
            yield return new WaitForSeconds(0.02f);
        }
    }

2回目以降の起動についてはオプション設定から、最後に開いたVRMファイルを自動的に読み込むように変更可能。ストレージからの直接読み込み処理については高速だった為、プログレスバーは非表示に。「デフォルトで入っている私のモデルを見続ける人はいない」という希望的観測に基づく実装。

アプリの公開

25$支払い可能なクレジットカード、スクリーンショットを数枚、GooglePlay用のバナー、アプリケーションのアイコン(32bit 透過 512*512)を用意しておく。 ビルド時の証明書設定やGooglePlayConsoleへの登録についてはぐぐったら知見が大量に出てくる。

64bit対応

APKをアップロードしたところ、64bitに対応しましょうと警告が。こちらの記事を参考に、ビルド設定を変更する。1.2GBくらいのSymbolファイルも一緒に出力されるようになるので、ビルドに必要な時間は増える。

2019 年 8 月 1 日以降、Google Play で公開するアプリは 64 ビット アーキテクチャをサポートする必要がある。

developer.android.com

そして謎のリジェクト

これは本当に謎です。念のため1.01のモジュールをアップしなおしたのですが、公開ページの修正でリリースが完了してしまい、1.0.0の公開直後にアップデートが走るという悲しい結果になりました。

最後に

アプリを起動して、せすれりあちゃん(中身♂設定)を表示しておくだけでスマホが使用不可能になり、作業が捗ることは間違いありません。つまりみんな私をすこれよ!!胸触られても怒らないからね!


  1. ベクトル距離で制限をかけるだけだと反対側に突き抜けてしまう。

VRMモデルのスカートめくりについて(途中)

忘れないうちに構想を記録しておく。(後日追記するかも)
VRアプリ以外でも実装しやすい仕組み。実装としてはスカートの内側にVRMSpringBoneColliderGroupを仕込んで置き、グリップ移動可能なコライダーを大きめの範囲(スカートの上側でもグリップできる位置)で調整しておく。
コライダーをグリップし、上に持ち上げる事により、スカートを内側から圧迫し、めくっているような動作をさせることが出来る。この実装による課題としては、コライダーの移動距離とスカートのめくれが同一の距離感になるようにすること。もっとリアリティのあるめくれ方として、ポンデリングのように、ドーナツ位置にVRMSpringBoneColliderGroupを複数設置、一番近いところをグリップすると、一番近い箇所と、その左右のVRMSpringBoneColliderGroupを同時に持ち上げることにより、リアルに近づくのではないか(仮定)。また、コライダーのサイズについては、髪の毛などと干渉しないように、可変にする必要があると思う。

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

いつもの

RenderTextureで実装する簡易鏡

概要

鏡もどきを簡単に実装する知見です。現実の鏡とは違い、どちらかと言えばカメラの映像を映した液晶ディスプレイ。RenderTextureを用いる事で、そのまま写真撮影にも応用できるので、そういった使い方をするのであれば、この手法がおすすめです。カメラのFOVも好きに調整できるので、平面的な写真が撮りやすい鏡も作れます。(FOVが低いほど平面的に写せてよい感じになる。)

実装方法

1.RenderTextureを作成。サイズは任意に変更。

f:id:sesleria:20190503134641p:plain:w400

2.Materialを作成。Albedoに1で作成したRenderTextureを設定し。ShaderStandardからLegacy Shaders/Self-Illumin/VertexLitに変更することで、明るく良い感じに表示される。

参考:フォーラムの情報

f:id:sesleria:20190503134657p:plain:w400

f:id:sesleria:20190413015403p:plain:w200f:id:sesleria:20190503135545p:plain:w200

※StandardとLitの違い

3.鏡に映すためのカメラを作成。TargetTextureに1で作成したRenderTextureをアタッチする。

4.Quadを作成。SizePositionを調整する。2で作成したMaterialを設定。拡大/縮小Xがマイナスになっているのは、鏡のように反転させるため。

f:id:sesleria:20190503134652p:plain:w400

5.Quadの子オブジェクトにCameraを設定、PositionfieldOfViewnearClipPlaneを調整する。Quadにカメラの映像が写っていれば完成。 f:id:sesleria:20190503140859p:plain:w400

カメラのPositionを計算する方法についてはこちらを参照

完成イメージ

f:id:sesleria:20190503144727g:plain:w400

以下はFOV30のカメラを使用してRenderTextureをPNG保存したものです。PNG化についてはRenderTexture png 保存とかでぐぐれば出てくるので割愛します。この鏡の実装について書いたのは、RenderTextureを適用したMaterialのShader設定について記述している記事が殆ど無かった為です。暗くて泣いていたUnity初心者はきっと私だけなのでしょう。

f:id:sesleria:20190503134704p:plain:w400

f:id:sesleria:20190503134707p:plain:w400

VRMのBlendShapeを良い感じに遷移させてみる

表情を変えるだけなら公式を参照すれば解決なのですが、作成中のアダルトアプリにおいて、表情の遷移が重要なエッセンスであると感じ、実装してみました。背景としてBlendShapeをAnimationに組み込むと、スクリプトからの変更が効かなくなるのに加え、同じ体位中(Animation)でも表情だけを変更したく、コードから変更したほうが良さそうだった為です。Mecanimのアバターマスクでの遷移は地獄なので。

前提として、またたきの処理についてはVRM標準のもの(Blinker.cs)をモデルにアタッチしています。
C#の理解度が低いので、かなりゴリ押しです。

KeepChangeBlendshapeEmoteでBlendShapeを5分割して遷移させてあげるだけの、シンプルな実装ですが、引数で細かい制御を可能にしています。emoteがBlendShapeの名前、ratioが表情の遷移分パラメータ、secが表情のキープ時間、lateが遅延時間、transitionが表情の遷移にかける時間、startが表情の初期パラメータです。transitionは実際には最低0.02secほどかかってしまうので、この時間を10倍した値を考慮して、遷移間隔を算出してあげる必要があります。

以下が、実際に動作させたイメージです。かわいいですね。
f:id:sesleria:20190502152409g:plain:w250f:id:sesleria:20190502152414g:plain:w250

ManageFacialExpressions.cs

VRoidで作成したVRMモデルにBlinker.csと、ManageFacialExpressions.csをアタッチすれば動きます。C V B で遷移、Nで中断。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using System;
using VRM;

public class ManageFacialExpressions : MonoBehaviour
{

    private Blinker blink;
    private VRMBlendShapeProxy proxy;
    private bool endFlag = true;
    private bool startFlag = true;
    private int newFace = 100;

    void Start()
    {
        proxy = this.GetComponent<VRMBlendShapeProxy>();
        blink = this.GetComponent<Blinker>();
    }

    void Update()
    {
        if(Input.GetKeyDown(KeyCode.C)){
            startFlag = false;
            newFace = 0;
        }

        if(Input.GetKeyDown(KeyCode.V)){
            startFlag = false;
            newFace = 1;
        }

        if(Input.GetKeyDown(KeyCode.B)){
            startFlag = false;
            newFace = 2;
        }

        if(Input.GetKeyDown(KeyCode.N)){
            blink.enabled = true;
            startFlag = false;
            newFace = 100;
        }

        if(endFlag && newFace != 100){
            blink.enabled = false;
            startFlag = true;
            endFlag = false;
            StartCoroutine(FaceAnimationStart(newFace));
        }
    }

    //表情を初期化
    public void ResetBlendShape(){
        proxy.SetValue("NEWTRAL",0.0f);
        proxy.SetValue("A",0.0f);
        proxy.SetValue("I",0.0f);
        proxy.SetValue("U",0.0f);       
        proxy.SetValue("E",0.0f);
        proxy.SetValue("O",0.0f);
        proxy.SetValue("BLINK",0.0f);
        proxy.SetValue("BLINK_L",0.0f);
        proxy.SetValue("BLINK_R",0.0f);
        proxy.SetValue("JOY",0.0f);
        proxy.SetValue("ANGRY",0.0f);
        proxy.SetValue("SORROW",0.0f);
        proxy.SetValue("FUN",0.0f);
        proxy.SetValue("SURPRISED",0.0f);
        proxy.SetValue("EXTRA",0.0f);
    }

    //表情制御(個別)
    public void KeepChangeBlendshapeEmote(string emote, float ratio, float sec, float late = 0.0f, float transition = 0.01f, float start = 0.0f){
        StartCoroutine(ChangingBlendshape(emote, ratio, sec, late, transition, start));
    }

    private IEnumerator ChangingBlendshape(string emote, float ratio, float sec, float late ,float transition, float start){
        
        yield return new WaitForSeconds(late);

        for(int i=5; i>=1; i--){
            proxy.SetValue(emote,start + ratio / i);
            yield return new WaitForSeconds(transition);
        }
        yield return new WaitForSeconds(sec);
        for(int i=1; i<= 5; i++){
            proxy.SetValue(emote,start + ratio / i);
            yield return new WaitForSeconds(transition);
        }

        proxy.SetValue(emote,start);

    }

    //表情制御(指示)
    private IEnumerator FaceAnimationStart(int faceNo){

        int blinkCount = 0;

        if(faceNo == 0){
            KeepChangeBlendshapeEmote("BLINK", 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
            while(startFlag){
                KeepChangeBlendshapeEmote("FUN", 0.15f, 1.2f, 0.0f, 0.1f, 0.15f);
                KeepChangeBlendshapeEmote("JOY", -0.15f, 1.2f, 0.0f, 0.1f, 0.3f);
                yield return new WaitForSeconds(2.5f);
                blinkCount++;
                if(blinkCount == 3){
                    blinkCount = 0;
                    KeepChangeBlendshapeEmote("BLINK", 0.6f, 0.0f, 0.0f, 0.01f, 0.0f); 
                }
            }
        }

        if(faceNo == 1){
            KeepChangeBlendshapeEmote("BLINK", 0.0f, 0.0f, 0.0f, 0.0f, 0.55f);
            while(startFlag){
                KeepChangeBlendshapeEmote("FUN", 0.15f, 1.2f, 0.0f, 0.1f, 0.15f);
                KeepChangeBlendshapeEmote("JOY", -0.15f, 1.2f, 0.0f, 0.1f, 0.3f);
                KeepChangeBlendshapeEmote("BLINK", 0.0f, 2.5f, 0.0f, 0.0f, 0.55f);
                yield return new WaitForSeconds(2.5f);
            }
        }

        if(faceNo == 2){
            KeepChangeBlendshapeEmote("BLINK", 0.0f, 0.0f, 0.0f, 0.0f, 0.0f);
            while(startFlag){
                KeepChangeBlendshapeEmote("A", 1.0f, 0.0f, 0.0f, 0.02f, 0.0f);
                KeepChangeBlendshapeEmote("I", 1.0f, 0.0f, 0.3f, 0.02f, 0.0f);
                KeepChangeBlendshapeEmote("U", 1.0f, 0.0f, 0.6f, 0.02f, 0.0f);
                KeepChangeBlendshapeEmote("E", 1.0f, 0.0f, 0.9f, 0.02f, 0.0f);
                KeepChangeBlendshapeEmote("O", 1.0f, 0.0f, 1.2f, 0.02f, 0.0f);
                yield return new WaitForSeconds(1.7f);
            }
        }
        
        endFlag = true;
        ResetBlendShape();        

    }
}

課題 : 元のBlendShapeの値をキープしつつ、変更先までのパラメータを加算なり減算してあげれば、一度リセットしてやらなくても済むのかもしれない。

作業進捗風景

VRMモデルになってVRMモデルに憑依するVRアプリを作ったお話

配布先

sesleria.booth.pm

紹介動画

Unity歴について

7年ほど前にアカウントは作成してあった。本格的に触り始めたのは二か月ほど前。最初にやったのはUdemyの講座。非常にわかりやすい。
ユニティちゃんが教える!初心者向けUnity講座 | Udemy

経緯

VRアプリの作成技術を高めたく、一人称視点のゾンビ討伐系VRアプリを作成しようと思っていたが、VR+TSF系のアプリが少なすぎることに気づいてしまい、方針転換。異性のアバターを使うだけならば、他のVRアプリでも同様の体験が可能だが、感情移入するためには元のアバターを表示し続けておくようなギミックが重要なのではないかと感じた(変身と精神の転移は異なるという解釈) 。

VRMの良いところ

VRoid Studioで高品質なモデルを作成可能。VRoid Hubにおいても第三者が使用可能のモデルがユーザーによって提供され続けている。また、ライセンスが明確な為、様々なゲームに採用しやすい。今回のアプリでVRMロード時に注意したポイントは[このアバターを用いて性的表現を演じることの許可][アバターに人格を与えることの許諾範囲][再配布・改変に関する許諾範囲]。VRoidについては脱衣機能を付与した為、改変のライセンスを厳密にチェックしている(Hubと自作のものではライセンスの確認箇所が多少異なる)。VRMファイルについてはStreamingAssetsから決め打ちのファイルパスを起動時に読み込み、Disallowのモデルが含まれていた場合、強制的に終了するようにしている。

VR開発について

タッチコントローラの互換性を持たせたかったので、OpenVRで作成。InputManagerは鬼畜。VR開発は一見ハードルが高そうに見えるが、MainCameraをGameObjectの子に変えて、以下のようにProjectSettingsを変更すれば良い。

f:id:sesleria:20190407032157p:plain
ProjectSettings

一人称視点のこだわり

憑依という設定を大事にしたく、カメラのクリッピング平面パラメータをギリギリのラインで調整している。VRMには頭を非表示にする機能がついているが、あえてそれは使っていない(頭の影が見えなくなる、髪の毛が見えなくなる為)。IKの処理については、Unity標準のものを使用している。失業中なのでFinalIKを買いにくかった・・。コントローラと乳首の位置が大幅にずれる現象の対処等、HMDやコントローラーの位置についての知見は深まったと思う。現実の体と仮想の体についてのサイズ差異については、あくまで自分自身で調整しているので、腕の長さや、首の長さによってはIKの処理がダメだったりするかもしれない。また、モデルの設定次第ではあるが、胸については手で触った時、必ず揺れるように調整している。こちらについてはMtoFにおけるTSFのお約束事項なので、重要視した。自撮り用カメラはトリガーで背面に切り替わるようにしてあり、倒れている自分自身を撮影出来る。

憑依体験について

精神体になり、体を重ねることで憑依できるギミックも考慮していたが、スピード感を大事にしたく、今回のように相手の体に手を入れる、コントローラーが振動する、そこでトリガーを引くと相手に体に入り込めるという体験にした。TSFシチュエーションとしては微妙だが、VR化すると相手の体に入った直後、元の体の意識が抜けるという非現実的な体験が可能になった。また、大型で高画質の鏡を置くことにより、憑依した自分をすぐに確認できるというTSFが好きな人には刺さるアプリになったのではないかと思う。憑依先のモデルについてはブレンドシェイプでまばたきをする程度のアニメーションしかさせていないが、相手が生きているという感じが出てくる。

言いたかったこと

TSF系VRアプリはブルーオーシャン

使用アセット

Very Animation - Asset Store
Abandoned Asylum - Asset Store トイレを借りました

PackageはProBuilderとPost Processing。建物はProBuilderで作成したメッシュにMaterialを貼り付けただけ。

大変参考になったサイト

OpenVR コントローラーの入力 - Unity マニュアル
102 2 レーザーコントローラー · yumemi-inc/vr-studies Wiki · GitHub
Unity標準のVR機能(UnityEngine.XR)メモ - フレームシンセシス

UnityのPlayer設定(解像度関連)変更後、設定が反映されない場合の対処法

Windowsアプリの仕様で、前回起動時の解像度情報がレジストリに保管されるようになっており、このレジストリを削除する必要がある。
例:ディスプレイ解像度ダイアログを無効に変更した後も、全画面表示され続けてしまう。

レジストリ削除手順

  1. [Windows+R]からregeditを起動
  2. 以下のレジストリを削除

\HKEY_CURRENT_USER\Software\企業名\プロダクト名

f:id:sesleria:20190324011046p:plain
Player設定画面

参考リンク:

docs.unity3d.com