Unity製のAndroidアプリをリリースしたお話(VRMascot)
はじめに
簡易的なAndroid向けのVRMビューアが欲しかったので、作ったというお話です。 多機能なアプリはいくつかリリースされているのですが、シンプルに見たいだけというニーズを満たせるものは無いように思いました。無いのなら作るしかない。作ればよい。アァァッ。
追記:
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 新リリース版では機能を排除(停止対策)
胸の周囲に設置したコライダーへのタッチ判定をScreenPointToRay
で取得。タッチした箇所のVRMSpringBoneColliderGroup
を調整して、胸を揺らしている。
※以下の記事に書いてある、囲い込み法を組み込み。
頭のタッチ時の処理についてもコライダーを仕込んでいる。細かく制御する場合は、部位ごとにコライダーを仕込んでタグとか名前で判定してあげれば良さそう。
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 ビット アーキテクチャをサポートする必要がある。
そして謎のリジェクト
これは本当に謎です。念のため1.01のモジュールをアップしなおしたのですが、公開ページの修正でリリースが完了してしまい、1.0.0の公開直後にアップデートが走るという悲しい結果になりました。ストアページのウェブサイトからtwitterのリンクを外したら審査通ったの謎すぎる pic.twitter.com/9aRyATxuya
— せすれりあ (@sesleria) 2019年5月23日
最後に
アプリを起動して、せすれりあちゃん(中身♂設定)を表示しておくだけでスマホが使用不可能になり、作業が捗ることは間違いありません。つまりみんな私をすこれよ!!胸触られても怒らないからね!
せすれりあちゃん(VRMモデル)に触れるアプリの宣伝動画です。https://t.co/XdGvmDINv5 pic.twitter.com/RVhPLIXmbC
— せすれりあ (@sesleria) 2019年5月23日
-
ベクトル距離で制限をかけるだけだと反対側に突き抜けてしまう。↩
VRMモデルのスカートめくりについて(途中)
忘れないうちに構想を記録しておく。(後日追記するかも)
VRアプリ以外でも実装しやすい仕組み。実装としてはスカートの内側にVRMSpringBoneColliderGroupを仕込んで置き、グリップ移動可能なコライダーを大きめの範囲(スカートの上側でもグリップできる位置)で調整しておく。
コライダーをグリップし、上に持ち上げる事により、スカートを内側から圧迫し、めくっているような動作をさせることが出来る。この実装による課題としては、コライダーの移動距離とスカートのめくれが同一の距離感になるようにすること。もっとリアリティのあるめくれ方として、ポンデリングのように、ドーナツ位置にVRMSpringBoneColliderGroupを複数設置、一番近いところをグリップすると、一番近い箇所と、その左右のVRMSpringBoneColliderGroupを同時に持ち上げることにより、リアルに近づくのではないか(仮定)。また、コライダーのサイズについては、髪の毛などと干渉しないように、可変にする必要があると思う。
VRMモデルのスカートをめくる技術(VRMSpringBoneColliderGroupのRadiusを0.2あたりに調整するだけ) #VRM憑依ちゃん pic.twitter.com/7ZCI0lcNCs
— せすれりあ (@sesleria) 2019年5月18日
VRMスカートめくり完全版です。ご査収ください。 pic.twitter.com/XAn1V0JujW
— せすれりあ (@sesleria) 2019年5月18日
ちなみにグリップのコライダーとVRMSpringBoneColliderGroupのポジションに差をつけてやるというのが上手にめくるコツです。コライダーの移動をY軸のみに固定して、モデルと親子関係、またグリップして上に移動するにつれてRadiusを可変調整すると、完璧にめくれるのではないかと思います。 pic.twitter.com/4jatkGrYRs
— せすれりあ (@sesleria) 2019年5月18日
UnityでVRMモデルの胸を揺らす知見
通常のVRMモデルを用いるアプリにおいて、このような知見は不要かもしれないが、特定の条件下でVRMモデルの胸を揺らしたいケースは必ず出てくると思う。衝突法がVRM憑依ちゃんの開発で使っている手法。囲い型は組み込み検討中。
必読
手法
VRアプリで胸を触る場合
VRアプリの場合は、手に追従するGameObjectに対しVRMSpringBoneColliderGroup.cs
をアタッチしてパラメータを調整、胸を揺らしたいVRMモデルのsecondary
にあるVRMSpringBone
(胸のRoot Bonesが設定された箇所)のCollider Groups
に手のGameObject
を指定。髪も触りたい場合は全てにColliderを設定すれば良い。半径を大きくしすぎると現実離れした胸揺れになるので、0.05~0.1あたりが最適と思われる。
オブジェクトの衝突
自分で触らずに、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
は設定されていないモデルが多いので、そういった場合は他のボーンから位置を取得して補正しなければならない。
左右から衝撃を与えるケースにおいて、Radius差による揺れ表現の違い
事前準備
Resourcesフォルダに以下のPrefabを作成。
コード
//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
いつもの
僕が一番上手にVRoidの胸を触れるんだ・・! #VRM憑依ちゃん pic.twitter.com/IQzu9rorxo
— せすれりあ (@sesleria) 2019年4月23日
RenderTextureで実装する簡易鏡
概要
鏡もどきを簡単に実装する知見です。現実の鏡とは違い、どちらかと言えばカメラの映像を映した液晶ディスプレイ。RenderTextureを用いる事で、そのまま写真撮影にも応用できるので、そういった使い方をするのであれば、この手法がおすすめです。カメラのFOVも好きに調整できるので、平面的な写真が撮りやすい鏡も作れます。(FOVが低いほど平面的に写せてよい感じになる。)
実装方法
1.RenderTexture
を作成。サイズは任意に変更。
2.Material
を作成。Albedo
に1で作成したRenderTexture
を設定し。Shader
をStandard
からLegacy Shaders/Self-Illumin/VertexLit
に変更することで、明るく良い感じに表示される。
※StandardとLitの違い
3.鏡に映すためのカメラを作成。TargetTexture
に1で作成したRenderTexture
をアタッチする。
4.Quad
を作成。Size
やPosition
を調整する。2で作成したMaterial
を設定。拡大/縮小
のX
がマイナスになっているのは、鏡のように反転させるため。
5.Quad
の子オブジェクトにCamera
を設定、Position
やfieldOfView
、nearClipPlane
を調整する。Quadにカメラの映像が写っていれば完成。
カメラのPosition
を計算する方法についてはこちらを参照
完成イメージ
以下はFOV30のカメラを使用してRenderTextureをPNG保存したものです。PNG化についてはRenderTexture
png
保存
とかでぐぐれば出てくるので割愛します。この鏡の実装について書いたのは、RenderTextureを適用したMaterialのShader設定について記述している記事が殆ど無かった為です。暗くて泣いていたUnity初心者はきっと私だけなのでしょう。
VRMのBlendShapeを良い感じに遷移させてみる
表情を変えるだけなら公式を参照すれば解決なのですが、作成中のアダルトアプリにおいて、表情の遷移が重要なエッセンスであると感じ、実装してみました。背景としてBlendShapeをAnimationに組み込むと、スクリプトからの変更が効かなくなるのに加え、同じ体位中(Animation)でも表情だけを変更したく、コードから変更したほうが良さそうだった為です。Mecanimのアバターマスクでの遷移は地獄なので。
前提として、またたきの処理についてはVRM標準のもの(Blinker.cs)をモデルにアタッチしています。
※C#の理解度が低いので、かなりゴリ押しです。
KeepChangeBlendshapeEmote
でBlendShapeを5分割して遷移させてあげるだけの、シンプルな実装ですが、引数で細かい制御を可能にしています。emote
がBlendShapeの名前、ratio
が表情の遷移分パラメータ、sec
が表情のキープ時間、late
が遅延時間、transition
が表情の遷移にかける時間、start
が表情の初期パラメータです。transition
は実際には最低0.02secほどかかってしまうので、この時間を10倍した値を考慮して、遷移間隔を算出してあげる必要があります。
以下が、実際に動作させたイメージです。かわいいですね。
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憑依ちゃん pic.twitter.com/Vw5tNCI1hI
— せすれりあ (@sesleria) 2019年4月27日
表情は5フレームくらいで遷移させるのが妥当なとこかしら。いかんせんUnity経験が足りなさすぎるゾ。 pic.twitter.com/E5IQzwtCSy
— せすれりあ (@sesleria) 2019年4月28日
VRoidの場合、AIUEOが高めのパラメータで被ると歯が飛び出ちゃう事故が起きる事があるので、そこさえ気を付ければ多少重ねたほうが自然に遷移しますな。 pic.twitter.com/gAh7F31hMP
— せすれりあ (@sesleria) 2019年4月28日
へんたい。 pic.twitter.com/wRCcDyw9VX
— せすれりあ (@sesleria) 2019年4月28日
えっち。 pic.twitter.com/Vo3tWlXVVS
— せすれりあ (@sesleria) 2019年4月28日
BLINKにマイナス入れたらどうなるんだろうと思ってやってみたら、壊れたアンドロイドみたいになってしまった。 pic.twitter.com/MYyVwRTiCM
— せすれりあ (@sesleria) 2019年4月28日
シェイプの初期値も考慮するようにしたけど、引数多すぎて地獄絵図だなぁ。 pic.twitter.com/3nYCdrOvQP
— せすれりあ (@sesleria) 2019年4月28日
VRMモデルになってVRMモデルに憑依するVRアプリを作ったお話
配布先
紹介動画
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を変更すれば良い。
一人称視点のこだわり
憑依という設定を大事にしたく、カメラのクリッピング平面パラメータをギリギリのラインで調整している。VRMには頭を非表示にする機能がついているが、あえてそれは使っていない(頭の影が見えなくなる、髪の毛が見えなくなる為)。IKの処理については、Unity標準のものを使用している。失業中なのでFinalIKを買いにくかった・・。コントローラと乳首の位置が大幅にずれる現象の対処等、HMDやコントローラーの位置についての知見は深まったと思う。現実の体と仮想の体についてのサイズ差異については、あくまで自分自身で調整しているので、腕の長さや、首の長さによってはIKの処理がダメだったりするかもしれない。また、モデルの設定次第ではあるが、胸については手で触った時、必ず揺れるように調整している。こちらについてはMtoFにおけるTSFのお約束事項なので、重要視した。自撮り用カメラはトリガーで背面に切り替わるようにしてあり、倒れている自分自身を撮影出来る。
憑依体験について
精神体になり、体を重ねることで憑依できるギミックも考慮していたが、スピード感を大事にしたく、今回のように相手の体に手を入れる、コントローラーが振動する、そこでトリガーを引くと相手に体に入り込めるという体験にした。TSFシチュエーションとしては微妙だが、VR化すると相手の体に入った直後、元の体の意識が抜けるという非現実的な体験が可能になった。また、大型で高画質の鏡を置くことにより、憑依した自分をすぐに確認できるというTSFが好きな人には刺さるアプリになったのではないかと思う。憑依先のモデルについてはブレンドシェイプでまばたきをする程度のアニメーションしかさせていないが、相手が生きているという感じが出てくる。
言いたかったこと
使用アセット
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)メモ - フレームシンセシス