- 投稿日:2021-02-21T19:43:48+09:00
UnityとMMD4Mecanimを使ってqeustネイティブ(非oculus link)環境でモデルを動かす
Unity + MMD4Mecanim + oculus quest(非oculus link)環境で発生した問題あれこれ
※下書き中
Windows上では問題なかったのに、quest上で動かすと発生した問題がメインです。
■制作物の方針
基本的には以下のような方針で作っているので、似たような方針の方の参考になればと。・モデルはHumanoid化して使い、モーションも汎用化する。
多数のモデルに多数のモーションを共有させると容量が肥大化します。
(50のモデルに100のモーションを登録する場合、モデルごとにモーションを専用化すると、50x100=5000のモーションデータが必要になる)
これはディスクにもメモリにも、アセットバンドル生成時間にも厳しいので、Humanoid化して、すべてのモデルで汎用モーションを使い回します。
汎用モーションは再現性は落ちますが、モデル間で共有できる上に、モーション1つあたりの容量も1/5程度なのでとてもリーズナブルです。・アニメーションはPlayable APIで動的にグラフを生成させる
大量のモーションを静的なグラフで管理するのは困難なので、任意のタイミングで任意のモーションに切り替えられるようにします。・物理演算はリアルタイムで
焼き込みの物理演算の方が再現性は高いですが、大量のモデル&大量のモーションすべてを焼き込むと膨大なデータ量になってしまいます。
物理演算はデバイス上でリアルタイムに計算しましょう。・IAnimationJobを使う(MMD関係ないですが)
左右対象などの似たようなモーションを大量にメモリに読み込むのは無駄ですので、同じモーションをIAnimationJobで改変して使い回せるようにします。■■■■■
quest上でモデルが表示されない。■現象
Windows上では問題なく表示されるのに、quest上では何も表示されません。■原因
MMD4Mecanimデフォルトのシェーダーがandroidに対応してないと思われます。■対応
Standardシェーダーに置き換えます。注意点としては、
・そのままStandardに置き換えても透過モーフを判別しないので、顔色がバグったようになる場合があります。
透過マテリアルは、standard化した後にRendering ModeをFadeにしておきます。
モデルによりますが、「effect」「face_emo」「eye_sh」「touka」「morph」といった名前のものは大体が透過が必要です。
「青ざめ」「頬ぞめ」「照れ」表情専用のマテリアルは見落としやすいので注意。色々な表情に変えてみて問題ないことを確認します。・Standardシェーダーは裏面からの描写に対応していません。
薄い上着やマント、スカートなどが、裏面から見ると透けて見えます。
Unityデフォルトでも両面描写のシェーダーはありますが、Standardシェーダーと併用すると違和感が出ます(特にDirectional Lightが強い環境で)
そこで、StandardシェーダーにCull Offを追加した両面シェーダーを用意し、「両面描写が必須なマテリアルにだけ」適用します
以下サイトではCull Off以外に色々やっていますが、衣装用ならCull Offだけの方が良いです。
参考:https://note.com/nanase_jp/n/n8de032347abdCull Offを追加したStandardシェーダーは、光に対して以下のような挙動になります。
・表面に当たった光で明るさが決まる。(裏面の明るさも、「表面に当たった光」で決まる)
・裏面に当たった光に影響しない。(裏面からしか光があたっていないと、表も裏も暗い)
理想的とはいえませんが、厳密に両面の明るさを管理すると無茶苦茶重くなるので妥協・Lockをかけないと、シェーダー設定が勝手に戻される
ちゃんとpmxファイルのMaterialを開き、下の方にあるMaterials上で、すべてのmaterialの「Locked」をチェックしておきます。
複数のpmxで同じMaterialを共有している場合は、忘れずにすべてのpmxで設定しておきます。・色が黒っぽくなったり、白っぽくなる場合は、Albedoの色を変えて調整します。
特に、hadaやfaceといった名前のマテリアルが真っ白の場合は、少し赤っぽく変えておきます。
Windows上では違和感なくてもquest上だと真っ白な肌の違和感が際立ちます。・Standard化する時にAlbedoの画像が外れて単色化してしまう場合があります。
Texture ShapeがCubeだと、StandardのAlbedoに使えないので、2Dに戻します。
(ただ、そういったものは色の調整だけで補正した方が良い場合が多いです。)■■■■■
歯が顎から飛び出したり、口が歪んだり■現象
大きく口を開けた時などに歯が顎から飛び出したり、口が歪んだりする。■原因
HumanoidのJawに歯が登録されていることが原因かも。
ほとんどのMMDモデルはJawに相当するJointを用意していません。(そもそも表情はモーフで管理するので不要)Humanoid変換時、顎の近くにあるJointを探して自動的に登録しますが、そこに「口」や「歯」が登録されてしまうと、顎を上げるモーションの際、代わりに歯や口が回転してしまいます。
Headの一番下のJawに変なものが登録されている場合、これを解除します。
ついでに、Toes(bodyの中にあるLeft Leg、Right Legの一番下。つま先)も、IKに紐づく重要な位置なのに誤登録されやすいので確認しておくと良いでしょう。■■■■■
MMD4MecanimAnimMorphHelperがモーフを更新しない■現象
MMD4MecanimAnimMorphHelperを使用して、モーションと同時にモーフデータも再生したがquest上では再生されない。■原因
よく分からないのでMMD4MecanimAnimMorphHelperの修正でごまかす。■対応
MMD4MecanimAnimMorphHelperを修正
再配布は不可なコードなので、どこを修正したかだけをメモ。変数宣言部に追記
private Dictionary<string, int> ShapeKeyList; public SkinnedMeshRenderer target;void Start()内でInitialize();の後に追記(モーフ変更するためのUCharを探す)
foreach (string Charname in new string[] { "U_Char_0", "U_Char_1", "U_Char_2", "U_Char_3", "U_Char_4", "U_Char_5", "U_Char_6" }) { if (transform.Find("U_Char").gameObject.transform.Find(Charname).gameObject.GetComponent<SkinnedMeshRenderer>().sharedMesh.blendShapeCount > 10) //登録が10以上ある { target = transform.Find("U_Char").gameObject.transform.Find(Charname).gameObject.GetComponent<SkinnedMeshRenderer>(); break; } } ShapeKeyList = new Dictionary<string, int>(); for (int index = 0; index < target.sharedMesh.blendShapeCount; index++) ShapeKeyList.Add(target.sharedMesh.GetBlendShapeName(index).Substring(target.sharedMesh.GetBlendShapeName(index).IndexOf(".") + 1), index);void IAnimModel._SetAnimMorphWeight( IMorph morph, float weight )の中で、morph.weight = した後に追記(モーフ変更メイン処理)
if (ShapeKeyList.ContainsKey(morph.name)) target.SetBlendShapeWeight(ShapeKeyList[morph.name], morph.weight * 100);void _StopAnim()の中で、_inactiveModelMorphSet.Add( morph );した後に追記(別animに切り替える際の全reset)
if (ShapeKeyList.ContainsKey(morph.name)) target.SetBlendShapeWeight(ShapeKeyList[morph.name], 0);ちょっといい加減な改修ですが、以下のような内容です。
・Start()時に、U_Char_*を0から順番に見ていって、登録が10以上あるU_Charを探す。
大体のモデルは、メインのモーフが若い番号のU_Charに登録されていて、特殊モーフが別のU_Charに分かれている感じなので、「あ」「い」「う」「え」「お」「まばたき」「ウィンク」などの汎用モーフは、最初に10以上の登録があるU_Charを探せば間違いないだろうという考えです。
多数のモデルに多数のモーションを共有するという方針上、モデル固有の特殊モーフは考慮する必要が無いので、この修正で妥協しています。・本来の処理では「morph.weight =」でモーフを変えているようですが、quest上ではこれが動作しないようなのでU_Charを操作して直接モーフを書き換える処理を追記しました。
・別animに切り替える際に_StopAnim()が走りますが、この中で、現在weightが0になっていないモーフをすべて0に戻します。
(これをしないと、前のanimの最後で特殊なモーフが有効になっていて、次のanimでそれを使っていない場合、前のモーフを残したまま次のanimが再生されて表情が壊れます。)■■■■■
■現象
quest上ではBulletPhysicsによる物理演算が動かない■原因
多分、Android用のBulletPhysicsはリアルタイム物理演算に対応していない。
(MMD4Mecanimのpluginsの中にMMD4MecanimBulletPhysics/Android/libsとあるが、これを使ったリアルタイム物理演算についていまいち情報が見つからなかった。解析はNGだと思うので諦める。)■対応
Unity標準のPhysXやPhysicsに置き換えるか、Dynamic Bone(有名な有料アセット)あたりを使う必要がありそう。ということで、安易にDynamic Boneに逃げました。
Dynamic Boneの使い方は詳しく紹介しているサイトが多くあるので割愛。
各所で補助ツールが公開されているので、併用すれば楽に調整できます。
参考:https://gist.github.com/gamebox777/0c993078f0e34d608a7d6ec165268009■■■■■■■■■
モデルのサイズ(localScale)を変えると揺れものがバグる■現象
モデルのサイズ(localScale)を変えても揺れもののサイズだけが変わらない他、挙動が不安定になる。■原因
モデルのBullet Physicsを切り忘れている。■対応
忘れずにモデルの「MMD4 Mecanim Model」の「Physics」からPhysics EngineをNoneに変えておきます。Bullet Physicsを切っておけば普通のHumanoidとして扱えるため、単純にモデルのサイズを変えることもできますし、
全体のlocalScaleをVector3(1.0f, 0.9f, 0.9f)、首から上をVector3(1.1f, 1.1f, 1.1f)などにすることで、モデル本体データを改変することなく、擬似的にデフォルメ化して表示することも可能です。(太め&頭でっかち、ねんどろいどみたいな)
※あくまで見た目を変えているだけなので、本体の縦横比の方は極端な値を入れては駄目です。手を上げると太く短いのに、手を横に伸ばすと細長く変形するようなことになります。
※dynamic boneでつけたコライダーも一緒に縮小されるので、ちゃんと部位を選んで調整していれば、デフォルメ化後もちゃんと動くはずです。
- 投稿日:2021-02-21T19:43:48+09:00
UnityとMMD4Mecanimを使ってqeustネイティブ(非oculus link)環境でモデルを動かす際の問題いろいろ
Unity + MMD4Mecanim + oculus quest(非oculus link)環境で発生した問題あれこれ
※下書き中
Windows上では問題なかったのに、quest上で動かすと発生した問題がメインです。
制作物の方針
quest上でモデルが表示されない
歯が顎から飛び出す 口が歪む
quest上で表情(モーフ)が変化しない(MMD4MecanimAnimMorphHelperが動かない)
一部のモーフが動作しない
モデルのサイズ(localScale)を変えると揺れものがバグる
quest上ではBulletPhysicsによる物理演算が動かない
Unity上で使うと違和感が出るモデルがある
モデルの頭や手足のオブジェクトを探して変数に保管
Dボーンのあるモデルがうまく動かない
モデルの一部を非表示にしたい
Playable APIでIAnimationJobによる左右反転とmixerを併用制作物の方針
基本的には以下のような方針で作っているので、似たような方針の方の参考になればと。
・モデルはHumanoid化して使い、モーションも汎用化する。
多数のモデルに多数のモーションを共有させると容量が肥大化します。
(50のモデルに100のモーションを登録する場合、モデルごとにモーションを専用化すると、50x100=5000のモーションデータが必要になる)
これはディスクにもメモリにも、アセットバンドル生成時間にも厳しいので、Humanoid化して、すべてのモデルで汎用モーションを使い回します。
汎用モーションは再現性は落ちますが、モデル間で共有できる上に、モーション1つあたりの容量も1/5程度なのでとてもリーズナブルです。・アニメーションはPlayable APIで動的にグラフを生成させる
大量のモーションを静的なグラフで管理するのは困難なので、任意のタイミングで任意のモーションに切り替えられるようにします。・物理演算はリアルタイムで
焼き込みの物理演算の方が再現性は高いですが、大量のモデル&大量のモーションすべてを焼き込むと膨大なデータ量になってしまいます。
物理演算はデバイス上でリアルタイムに計算しましょう。・IAnimationJobを使う(MMD関係ないですが)
左右対象などの似たようなモーションを大量にメモリに読み込むのは無駄ですので、同じモーションをIAnimationJobで改変して使い回せるようにします。・可能な限り、モデルデータを改変しなくて良い方法を探す(MMD以外での利用OKでも、改変はNGなモデルも多いですし)
quest上でモデルが表示されない
■現象
Windows上では問題なく表示されるのに、quest上では何も表示されません。■原因
MMD4Mecanimデフォルトのシェーダーがandroidに対応してないと思われます。■対応
Standardシェーダーに置き換えます。注意点としては、
・そのままStandardに置き換えても透過モーフを判別しないので、顔色がバグったようになる場合があります。
透過マテリアルは、standard化した後にRendering ModeをFadeにしておきます。
モデルによりますが、「effect」「face_emo」「eye_sh」「touka」「morph」といった名前のものは大体が透過が必要です。
「青ざめ」「頬ぞめ」「照れ」表情専用のマテリアルは見落としやすいので注意。色々な表情に変えてみて問題ないことを確認します。・部分的に向こう側が見える非透明な切り抜き細工などはFadeではなくcutoutを設定する。
・Standardシェーダーは裏面からの描写に対応していません。
薄い上着やマント、スカートなどが、裏面から見ると透けて見えます。
Unityデフォルトでも両面描写のシェーダーはありますが、Standardシェーダーと併用すると違和感が出ます(特にDirectional Lightが強い環境で)
そこで、StandardシェーダーにCull Offを追加した両面シェーダーを用意し、「両面描写が必須なマテリアルにだけ」適用します
以下サイトではCull Off以外に色々やっていますが、衣装用ならCull Offだけの方が良いです。
参考:https://note.com/nanase_jp/n/n8de032347abdCull Offを追加したStandardシェーダーは、光に対して以下のような挙動になります。
・表面に当たった光で明るさが決まる。(裏面の明るさも、「表面に当たった光」で決まる)
・裏面に当たった光に影響しない。(裏面からしか光があたっていないと、表も裏も暗い)
理想的とはいえませんが、厳密に両面の明るさを管理すると無茶苦茶重くなる。。。(多分、やり方が間違っている。。。)
衣服のテクスチャで裏面から光が当たって違和感出るのはマントや大型リボンくらいなので妥協。・Lockをかけないと、シェーダー設定が勝手に戻される
ちゃんとpmxファイルのMaterialを開き、下の方にあるMaterials上で、すべてのmaterialの「Locked」をチェックしておきます。
複数のpmxで同じMaterialを共有している場合は、忘れずにすべてのpmxで設定しておきます。・色が黒っぽくなったり、白っぽくなる場合は、Albedoの色を変えて調整します。
特に、hadaやfaceといった名前のマテリアルが真っ白の場合は、少し赤っぽく変えておきます。
Windows上では違和感なくてもquest上だと真っ白な肌の違和感が際立ちます。・Standard化する時にAlbedoの画像が外れて単色化してしまう場合があります。
Texture ShapeがCubeだと、StandardのAlbedoに使えないので、2Dに戻します。
(ただ、そういったものは色の調整だけで補正した方がきれいに見える場合が多いです。)歯が顎から飛び出す 口が歪む
■現象
大きく口を開けた時などに歯が顎から飛び出したり、口が歪んだりする。■原因
HumanoidのJawに歯が登録されていることが原因かも。
ほとんどのMMDモデルはJawに相当するJointを用意していません。(そもそも表情はモーフで管理するので不要)Humanoid変換時、顎の近くにあるJointを探して自動的に登録しますが、そこに「口」や「歯」が登録されてしまうと、顎を上げるモーションの際、代わりに歯や口が回転してしまいます。
Headの一番下のJawに変なものが登録されている場合、これを解除します。
ついでに、Toes(bodyの中にあるLeft Leg、Right Legの一番下。つま先)も、IKに紐づく重要な位置なのに誤登録されやすいので確認しておくと良いでしょう。quest上で表情が変化しない
■現象
MMD4MecanimAnimMorphHelperでモーションと同時にモーフデータも再生したが、quest上では再生されない。■原因
なぜかquest上ではMMD4MecanimAnimMorphHelperが動かない。
よく分からないのでMMD4MecanimAnimMorphHelperの適当な修正でごまかす。
ちなみにMMD4MecanimMorphHelperによる表情変更もquest上では動かない。■対応
MMD4MecanimAnimMorphHelperを修正
再配布は不可なコードなので、どこを修正したかだけをメモ。変数宣言部に追記
private Dictionary<string, int> ShapeKeyList; public SkinnedMeshRenderer target;void Start()内でInitialize();の後に追記(モーフ変更するためのUCharを探す)
foreach (string Charname in new string[] { "U_Char_0", "U_Char_1", "U_Char_2", "U_Char_3", "U_Char_4", "U_Char_5", "U_Char_6" }) { if (transform.Find("U_Char").gameObject.transform.Find(Charname).gameObject.GetComponent<SkinnedMeshRenderer>().sharedMesh.blendShapeCount > 10) //登録が10以上ある { target = transform.Find("U_Char").gameObject.transform.Find(Charname).gameObject.GetComponent<SkinnedMeshRenderer>(); break; } } ShapeKeyList = new Dictionary<string, int>(); for (int index = 0; index < target.sharedMesh.blendShapeCount; index++) ShapeKeyList.Add(target.sharedMesh.GetBlendShapeName(index).Substring(target.sharedMesh.GetBlendShapeName(index).IndexOf(".") + 1), index);void IAnimModel._SetAnimMorphWeight( IMorph morph, float weight )の中で、morph.weight = した後に追記(モーフ変更メイン処理)
if (ShapeKeyList.ContainsKey(morph.name)) target.SetBlendShapeWeight(ShapeKeyList[morph.name], morph.weight * 100);void _StopAnim()の中で、_inactiveModelMorphSet.Add( morph );した後に追記(別animに切り替える際の全reset)
if (ShapeKeyList.ContainsKey(morph.name)) target.SetBlendShapeWeight(ShapeKeyList[morph.name], 0);ちょっといい加減な改修ですが、以下のような内容です。
・Start()時に、U_Char_*を0から順番に見ていって、登録が10以上あるU_Charを探す。
大体のモデルは、メインのモーフが若い番号のU_Charに登録されていて、特殊モーフが別のU_Charに分かれている感じなので、「あ」「い」「う」「え」「お」「まばたき」「ウィンク」などの汎用モーフは、最初に10以上の登録があるU_Charを探せば間違いないだろうという考えです。
多数のモデルに多数のモーションを共有するという方針上、モデル固有の特殊モーフは考慮する必要が無いので、この修正で妥協しています。・本来の処理では「morph.weight =」でモーフを変えているようですが、quest上ではこれが動作しないようなのでU_Charを操作して直接モーフを書き換える処理を追記しました。
・別animに切り替える際に_StopAnim()が走りますが、この中で、現在weightが0になっていないモーフをすべて0に戻します。
(これをしないと、前のanimの最後で特殊なモーフが有効になっていて、次のanimでそれを使っていない場合、前のモーフを残したまま次のanimが再生されて表情が壊れます。)一部のモーフが動作しない
先の処理を行っても、一部のモーフだけ動作しない場合
■原因
そのモーフがMMD4 Mecanim ModelのMorphタブには存在するのに、U_Char_*にBlendShapesとして登録されていない場合、グループモーフの可能性がある。
グループモーフとは、PmxEditor上でモーフを見るとGとなっているもの。「笑い」や「まばたき」が左右の「ウィンク」のグループで表現されているなど。
グループモーフもMMD4Mecanimで取り込むことは可能だが、U_Char_*に登録されないので、先のMMD4MecanimAnimMorphHelper改修では再生されない。
そもそもスクリプト修正内容とは無関係に、Unity上では「笑い:100」「ウィンク:0」の譜面があると「ウィンク」の方が優先されて、実質的に「笑い」が無効化されるような問題も生じる。
そういった譜面が無いようにモーフデータを調整すれば良い話ではあるが、Unity上で汎用的に使うのにグループモーフはあまり望ましくない気がする。■対応
Unity上のスクリプトで対応するのは煩雑すぎるので、モデルのモーフデータを改修するのが無難な気がする。
できればモデルデータそのものの改修は避けたいが妥協。PMXエディッタのモーフ編集で、グループ元となるモーフ「ウィンク」「ウィンク右」を両方選択して、ctrl+c(コピー)。
メモ帳に貼り付けて、「ウィンク」と「ウィンク右」を「笑い」に置換。途中に不要なヘッダ行が混ざるので消しておく。
再びPMXエディッタのモーフ編集画面でctrl+Vすると、「笑い」モーフが通常モーフに置き換えられる。quest上で物理演算が動かない
■原因
多分、Android用のBulletPhysicsはリアルタイム物理演算に対応していない。
(MMD4Mecanimのpluginsの中にMMD4MecanimBulletPhysics/Android/libsとあるが、これを使ったリアルタイム物理演算についていまいち情報が見つからなかった。解析はNGだと思うので諦める。)■対応
Unity標準のPhysXやPhysicsに置き換えるか、Dynamic Bone(有名な有料アセット)あたりを使う必要がありそう。ということで、安易にDynamic Boneに逃げました。
Dynamic Boneの使い方は詳しく紹介しているサイトが多くあるので割愛。
各所で補助ツールが公開されているので、併用すれば楽に調整できます。
参考:https://gist.github.com/gamebox777/0c993078f0e34d608a7d6ec165268009Dynamic BoneのDynamic Bone Colliderを付ける際、Bullet Physicsの「Generate Colliders」で付与されるcollider(Coll.~)オブジェクトを参考に付けると楽かもしれない。
「Coll.~」の名前を「DB.~」に変えてからDynamic Bone Colliderを付与。Capsule ColliderのRadius/Height/Directionを、そのままDynamic Bone Colliderの同名のパラメータに変えれば良い。Capsule Colliderコンポーネントは削除。
最後にBullet Physicsの「Remove Colliders」するのを忘れずに。残しておくとquest上では非常に重いので。
名前が「Coll.~」のものを消す仕組みらしく、「DB.~」にリネームしておけば消されることはありません。
colliderは、揺れものとの判定を考えて必要なものだけを付けること。短髪の髪の毛に対して、足の当たり判定を付けるのは時間の無駄。モデルのサイズを変えると揺れものがバグる
■現象
モデルのサイズ(localScale)を変えても揺れもののサイズだけが変わらない他、挙動が不安定になる。■原因
モデルのBullet Physicsを切り忘れている。■対応
忘れずにモデルの「MMD4 Mecanim Model」の「Physics」からPhysics EngineをNoneに変えておきます。Physics Engineを切り忘れているということは「Generate Colliders」で生成されるcolliderも消し忘れているかもしれないので、確認。
MMDのcolliderはかなり細かいので、残しておくとquest上のパフォーマンスが劇的に落ちる。当たり判定のために使うにしても、もっと簡易化したcolliderを独自につけた方が良い。Bullet Physicsを切っておけば普通のHumanoidとして扱えるため、単純にモデルのサイズを変えることもできますし、
全体のlocalScaleをVector3(1.0f, 0.9f, 0.9f)、首から上をVector3(1.1f, 1.1f, 1.1f)などにすることで、モデル本体データを改変することなく、デフォルメ風に表示することも可能です。(太め&頭でっかち、ねんどろいどみたいな)
※あくまで見た目を変えているだけなので、本体の縦横比の方は極端な値を入れては駄目です。手を上げると太く短いのに、手を横に伸ばすと細長く変形するようなことになります。
※dynamic boneでつけたコライダーも一緒に縮小されるので、適切な部位に付与していれば、デフォルメ化後もちゃんと動くはずです。違和感が出るモデル
MMDとして使えば問題無いのに、Unity上では、あるいはquest上では違和感が出るもの。
違和感といっても色々あると思いますが、前述のテクスチャの工夫ではどうしても消せないものについて。■現象1 モーフを変えると顔に凸凹ができて影が生じる
モデルの構造上、そうなっているっぽい。
何らかのモーフの重みが1つでも1以上になると、発生する。「あ」「い」のような部分的な汎用モーフでも発生。
MMDとして使っている分は目立たないが、Unity環境では細かい凹凸によってできる影が非常に目立ってしまう。(シワのように見える)Directional Lightを抑えて、環境光多めにすれば多少の軽減は可能。
※Rendering→Lighting→Environmentで、Environment Lighting→SourceをColorにして、Ambient Colorを明るめの色に、
Environment ReflectionsのSourceもColorにする根本的には、モデルそのものを改変しないと解決できない気がする。何か良い方法は無いものか。
■現象2 顔に写った影が動かない
テクスチャ画像に影が書き込まれているかも?(アニメ絵的なモデルでありがち。)
髪の毛が揺れても、髪の毛の影が微動だにしないため違和感が。テクスチャ画像をいじって影を消すしか無い。
モデルの頭や手足のオブジェクトを探して変数に保管
■現象
モーションデータではなく、モデルの動きをスクリプトで細かく制御する場合、モデルの各部位を変数として取得したい。
しかし各部位のオブジェクト名や構造はモデルによって異なる上、モデルを修正すると変わる(数字が連番で振り直される)。■対応
自身の子オブジェクトに含まれる名前をcontainsで探す関数を用意しておいて、
private Transform GetChildByContain(Transform obj, string targetstr) { foreach (Transform childobj in obj) { if (childobj.name.Contains(targetstr)) return childobj; } return null; //指定の文字列のオブジェクトが見つからなければnullを返す }それを使って汎用的な名前をヒントに潜っていく。
たとえば"301.!Root/202.joint_HipMaster/203.joint_LeftHip"の場合、文字列「Root」を含む子供を探して、その中に「HipMaster」を含む子供を、その中に「LeftHip」を含む、、のように探す。
以下は、HipMaster 、Foot_L、Foot_R、SoulderBase(=肩直前のTorso)、Hand_L、Hand_R、Neckを探した場合。
Transform TargetObj = transform; Transform TargetObj_tmp = transform; TargetObj = GetChildByContain(TargetObj, "Root"); HipMaster = GetChildByContain(TargetObj, "HipMaster"); TargetObj = GetChildByContain(HipMaster, "LeftHip"); TargetObj = GetChildByContain(TargetObj, "LeftKnee"); Foot_L = GetChildByContain(TargetObj, "LeftFoot"); TargetObj = GetChildByContain(HipMaster, "RightHip"); TargetObj = GetChildByContain(TargetObj, "RightKnee"); Foot_R = GetChildByContain(TargetObj, "RightFoot"); //Torsoが複数段階になっている場合があるので、「LeftShoulder」が見つかるまでTorsoを潜る。Torso→Torso1→Torso2→Torso3・・・ TargetObj = GetChildByContain(HipMaster, "Torso"); for (int x = 1; x <= 20; ++x) { TargetObj_tmp = GetChildByContain(TargetObj, "LeftShoulder"); if (TargetObj_tmp == null) //LeftShoulderが見つからなければ、Torsoを潜って次のループへ { TargetObj = GetChildByContain(TargetObj, "Torso"); if (TargetObj == null) { Debug.Log("MMD_Model "+this.name + " Error:Not find Soulder and Torso"); //どちらも見つからないとエラー。 } continue; } else //見つかったなら、それを見つけた場所をBaseに設定。(_tmpにはLeftSoulderが入っている) { SoulderBase = TargetObj; break; } } TargetObj = GetChildByContain(SoulderBase, "Neck"); if (TargetObj == null) { Debug.Log("MMD_Model " + this.name + " Not found Neck"); } Head = GetChildByContain(TargetObj, "Head"); TargetObj = GetChildByContain(SoulderBase, "LeftShoulder"); TargetObj = GetChildByContain(TargetObj, "LeftArm"); TargetObj = GetChildByContain(TargetObj, "LeftElbow"); Hand_L = GetChildByContain(TargetObj, "LeftWrist"); TargetObj = GetChildByContain(SoulderBase, "RightShoulder"); TargetObj = GetChildByContain(TargetObj, "RightArm"); TargetObj = GetChildByContain(TargetObj, "RightElbow"); Hand_R = GetChildByContain(TargetObj, "RightWrist");独特なボーン構造をしている一部のモデルは対応できないので、どうしても個別設定が必要
(Neckが「Neck1-sub1-Neck2-sub2」みたいに多重になっていたり、HeadがJawの子供にあったりするのに汎用的に対応するのは難しい。
再帰的に探した全オブジェクトから「Head」を探すと、それはそれで変なものを広ってしまう。)
また、LeftとRightを間違って命名しているモデルも結構あるので、そういったものには対応できない。dボーンのあるモデルがうまく動かない
■現象
Dボーンのあるモデルを変換すると、足先が伸びたり、ふとももが変な角度にねじ曲がったりする。Humanoid設定のMuscles & Settings画面を確認すると、本来はこんなふうになるところ、
■原因
HumanoidはUnityの仕組みなので、MMDのDボーンに対応していない。■対応
モデルをInstantiateしてすぐに、Dボーンを通常ボーンの子供になるようにjointの親子関係を変えれば違和感はほぼ無くなる。
localPostionは0,0,0にしておけば大体は問題ないが、Toesはデフォルトでも良いかも?Transform Hip_L_Master = this.transform.Find("301.!Root/202.joint_HipMaster/203.joint_LeftHip"); Transform Hip_L_Sub = this.transform.Find("301.!Root/202.joint_HipMaster/215.!joint_LeftHipD"); Transform Knee_L_Master = this.transform.Find("301.!Root/202.joint_HipMaster/203.joint_LeftHip/204.joint_LeftKnee"); Transform Knee_L_Sub = this.transform.Find("301.!Root/202.joint_HipMaster/215.!joint_LeftHipD/216.!joint_LeftKneeD"); Transform Foot_L_Master = this.transform.Find("301.!Root/202.joint_HipMaster/203.joint_LeftHip/204.joint_LeftKnee/206.joint_LeftFoot"); Transform Foot_L_Sub = this.transform.Find("301.!Root/202.joint_HipMaster/215.!joint_LeftHipD/216.!joint_LeftKneeD/217.!joint_LeftFootD"); Transform Toes_L_Master = this.transform.Find("301.!Root/202.joint_HipMaster/203.joint_LeftHip/204.joint_LeftKnee/206.joint_LeftFoot/207.!joint_LeftToe"); Transform Toes_L_Sub = this.transform.Find("301.!Root/202.joint_HipMaster/215.!joint_LeftHipD/216.!joint_LeftKneeD/217.!joint_LeftFootD/218.joint_hidariashikubiEX"); Transform Hip_R_Master = this.transform.Find("301.!Root/202.joint_HipMaster/209.joint_RightHip"); Transform Hip_R_Sub = this.transform.Find("301.!Root/202.joint_HipMaster/219.!joint_RightHipD"); Transform Knee_R_Master = this.transform.Find("301.!Root/202.joint_HipMaster/209.joint_RightHip/210.joint_RightKnee"); Transform Knee_R_Sub = this.transform.Find("301.!Root/202.joint_HipMaster/219.!joint_RightHipD/220.!joint_RightKneeD"); Transform Foot_R_Master = this.transform.Find("301.!Root/202.joint_HipMaster/209.joint_RightHip/210.joint_RightKnee/212.joint_RightFoot"); Transform Foot_R_Sub = this.transform.Find("301.!Root/202.joint_HipMaster/219.!joint_RightHipD/220.!joint_RightKneeD/221.!joint_RightFootD"); Transform Toes_R_Master = this.transform.Find("301.!Root/202.joint_HipMaster/209.joint_RightHip/210.joint_RightKnee/212.joint_RightFoot/213.!joint_RightToe"); Transform Toes_R_Sub = this.transform.Find("301.!Root/202.joint_HipMaster/219.!joint_RightHipD/220.!joint_RightKneeD/221.!joint_RightFootD/222.joint_migiashikubiEX"); Hip_L_Sub.SetParent(Hip_L_Master, true); Hip_L_Sub.localPosition = new Vector3(0, 0, 0); Knee_L_Sub.SetParent(Knee_L_Master, true); Knee_L_Sub.localPosition = new Vector3(0, 0, 0); Foot_L_Sub.SetParent(Foot_L_Master, true); Foot_L_Sub.localPosition = new Vector3(0, 0, 0); Toes_L_Sub.SetParent(Toes_L_Master, true); Hip_R_Sub.SetParent(Hip_R_Master, true); Hip_R_Sub.localPosition = new Vector3(0, 0, 0); Knee_R_Sub.SetParent(Knee_R_Master, true); Knee_R_Sub.localPosition = new Vector3(0, 0, 0); Foot_R_Sub.SetParent(Foot_R_Master, true); Foot_R_Sub.localPosition = new Vector3(0, 0, 0); Toes_R_Sub.SetParent(Toes_R_Master, true);複数のDボーンが使われている場合、すべて同じように修正する。手に同様のjointがある場合も同じように修正する。
※こういったボーンは、joint名の末尾に、D、D2、DS、EX、sub、C、Sといった文字がついていることが多いので、それを参考に探す。
※腕にIKを仕込んでいたりする場合、jointではなくIKの方をjointにあわせる必要があるので注意。Unity humanoidで腕IKは無理。Dボーンの名前は統一性がなく、モデル作者によってバラバラなので個別に確認が必要。
どれが問題のjointか分からない時は、とりあえずHierarchy上に放り込んで、jointの親子関係いじりながら関節の角度を変更して試していれば、数分で確認できると思う。モデルの一部を非表示にしたい
■やりたいこと
装飾具がついているモデルの一部を消したい。(帽子、メガネ、艦娘の武装など)
最初から消したものを用意しておくのではなく、任意のタイミングで消したい。■対応方1 モーフで消せる場合
モーフで消せる場合は、それで対応します。
該当のモーフが含まれるU_Char_*を探して、それにSetBlendShapeWeight(モーフ番号, 100)すればOKです。
※MMD4MecanimAnimMorphHelperなどと併用する場合、こちらの処理が優先されるようにLateUpdateで毎フレーム設定するのが無難ただし部位を消すモーフには2種類あり、
・頂点操作で部位を縮小することで消すタイプ
後者は、おそらくUnityに取り込んでも動かないので、モデルを修正するか別の手法を試します。
■対応方2 消したい部位が特定の材質全体の場合
特定の材質(material)を透明化すれば良いだけ。本当にその材質すべてを消して良いのか、PmxEditorで確認しておきます。(髪飾りだけを消したつもりが、服の装飾も同じ材質で設定されていて同時に消えてしまうといったことがあり得ます。)
PmxEditorの「絞」から「頂点/材質」→「材質」「パーツ毎」を使って、対象の材質のみをチェック、消したいパーツ以外が画面に表示されていないことを確認すればOK。手順:
最初に透明なマテリアルを用意(Create→Materialで新規作成し、Rendering ModeをCutoutに、Albedoの色でA値を0にすればOK)透明マテリアルをinvisibleMat変数に入れておいて、それを部位に適用する関数を用意
private void Invisible(SkinnedMeshRenderer smr, int num) { Material[] mats = smr.materials; mats[num] = invisibleMat; smr.materials = mats; }あとは消したい部位がU_Charの何番目に入っているかを確認し、そこを指定する。
Invisible(transform.Find("U_Char/U_Char_0").GetComponent<SkinnedMeshRenderer>(), 1);該当のマテリアルにDynamic Boneの設定なども入っている場合は、違和感出ないようにDestroyしましょう。(Destroyは非可逆なので、戻す場合があるならSetActive(false)にする。)
private void DestroyIfExist(GameObject Target,string path) { if (Target.transform.Find(path) != null) GameObject.Destroy(Target.transform.Find(path).gameObject); //else // Debug.Log(path + " not found"); }DestroyIfExist(this, "138.!Root/117.joint_HipMaster/3.joint_Torso/9.joint_kazari");■対応方3 同じマテリアルで作成された部位のうち、一部だけを消す。
モデルデータを改変せずに実現するのは困難と思うので諦める。
問題の材質をFadeにして、消したい部位を優先度の高い透明Fade材質で覆い、且つ背景の描写優先度をより高くすることで光学迷彩みたいな隠し方もできますが、シーン全体のmaterialに工夫が必要になるので色々と弊害が出る。(Fadeで半透明化したいものは色々あるのに、それらの描写にも影響が、、、)左右反転とmixerを併用
■やりたいこと
前のモーションから次のモーションに自然に移行するためにはmixerを使うのが一番楽そう。
必要に応じてIAnimationJobで左右反転したモーションも使いたい。(ON/OFFを切り替えたい)■困ったこと
現状、IAnimationJobだけでモーションを完全に左右反転するには、BodyLocalRotationではなくbodyRotationを、BodyLocalPositionではなくbodyPositionを反転させる必要がある様子。(つまり、グローバル座標で鏡の世界に行ってもらう必要がある。)
左右反転すると同時にグローバル座標を入れ替えることで、その場で左右反転モーションを再生したかのように見せます。■やったこと
・左右反転用のIAnimationJobは、以下にはられているMirrorPoseJobを使用
https://forum.unity.com/threads/playables-api-mirroring-clips-and-directorupdatemode-manual.504533/・現在再生中のAnimationClipはcurrentPlayableに、直前まで再生していたAnimationClipはprePlayableに保管。
・nowMirrorとisMirrorの2つのbool 変数を用意。
モーション切り替えのタイミングでisMirrorがtrueになっている場合は、左右反転させる。
nowMirrorは現在反転しているかどうかを保管。使うもの
using UnityEngine.Playables; using UnityEngine.Animations; using UnityEngine.Timeline;使う変数
PlayableGraph graph; AnimationMixerPlayable mixer; private AnimationClipPlayable prePlayable, currentPlayable; private AnimationScriptPlayable preScriptPlayable, currentScriptPlayable; public bool isMirror = false; public bool nowMirror = false;初期化時に以下を実施
//普通にgraphとmixerを定義。 graph = PlayableGraph.Create(); var output = AnimationPlayableOutput.Create(graph, "output", GetComponent<Animator>()); mixer = AnimationMixerPlayable.Create(graph, 2, true); //IAnimationJobのScriptを定義。モーション切り替え時には2つのモーションを同時処理するので2つ。 currentScriptPlayable = AnimationScriptPlayable.Create(graph, new MirrorPoseJob(), 1); preScriptPlayable = AnimationScriptPlayable.Create(graph, new MirrorPoseJob(), 1); //出力はmixerで固定。mixerへの入力にScriptPlayableを通すかどうかを切り替える。 output.SetSourcePlayable(mixer); mixer.SetInputWeight(0, 1); graph.Play();新しいモーション(AnimationClip newAnimation)に切り替える際、コルーチンでmixerの割合を変えながら、前のモーションから次のモーションに少しずつ切り替える。
参考:https://gist.github.com/tsubaki/24565b08cc8c0ec3b1614b7bf6edaa5b
そのタイミングでisMirrorがnowMirrorと異なる場合は、mixerの入力を切り替えつつグローバル座標を反転。
isMirrorがtrue:mixerの2つの入力両方ともScriptPlayable経由で新旧それぞれのAnimationClipに接続
isMirrorがfalse:mixerの2つの入力を新旧それぞれのAnimationClipに直接接続prePlayable = currentPlayable; currentPlayable = AnimationClipPlayable.Create(graph, newAnimation); if (isMirror == false) { if (nowMirror == true) { this.transform.position = new Vector3(-this.transform.position.x, this.transform.position.y, this.transform.position.z); this.transform.eulerAngles = new Vector3(this.transform.eulerAngles.x, -this.transform.eulerAngles.y, this.transform.eulerAngles.z); nowMirror = false; } mixer.ConnectInput(1, prePlayable, 0); mixer.ConnectInput(0, currentPlayable, 0); } else { if (nowMirror == false) { this.transform.position = new Vector3(-this.transform.position.x, this.transform.position.y, this.transform.position.z); this.transform.eulerAngles = new Vector3(this.transform.eulerAngles.x, -this.transform.eulerAngles.y, this.transform.eulerAngles.z); nowMirror = true; } graph.Connect(prePlayable, 0, preScriptPlayable, 0); mixer.ConnectInput(1, preScriptPlayable, 0); graph.Connect(currentPlayable, 0, currentScriptPlayable, 0); mixer.ConnectInput(0, currentScriptPlayable, 0); } float waitTime = Time.timeSinceLevelLoad + transitionTime; yield return new WaitWhile(() => { var diff = waitTime - Time.timeSinceLevelLoad; if (diff <= 0) { mixer.SetInputWeight(1, 0); mixer.SetInputWeight(0, 1); return false; } else { var rate = Mathf.Clamp01(diff / transitionTime); mixer.SetInputWeight(1, rate); mixer.SetInputWeight(0, 1 - rate); return true; } });■要改善点1
この実装、isMirrorが変わったと同時にmixerの入力の両方を切り替えているので、isMirrorが切り替わった瞬間のポーズは、どうしても反転してしまう。
反転した後、旧モーションは反転前で、新モーションは反転後になるように、mixerの入力を順番に切り替える実装にするべき。■要改善点2
長いモーションの場合、モーションの途中で左右反転したい場合もあるかも?■要改善点3
そもそも左右反転をもっと簡単に実装できれば良いのだけれど、いまのところ他に良い方法が見当たらない。
・反転したデータを別途用意しておく→モーションデータ量が単純に倍になる。ディスク&メモリの無駄
・モデル自体のlocalScaleを反転させる→左右非対称のモデルでは不可
・Animation StateのMirror的な機能→現状、おそらくPlayable APIからは利用不可?
- 投稿日:2021-02-21T17:59:22+09:00
UIElementsとOdinEditorWindowをあわせてみる
いやはや、UIElementsを使っているのですけどね
資料もないし大変ですね・・・なんだろう結構複雑なEditor拡張を作るのにはまあIMGUIより向いているというのはまあ、そのとおりだと思うのですけど
「これからはこれを使ってエディタ拡張を書いていこう!」になるには結構時間がかかりそうな印象です。一方Odinという拡張があります
https://assetstore.unity.com/packages/tools/utilities/odin-inspector-and-serializer-89041?aid=1101lGoY&utm_source=affこれはIMGUI(OnGUI)を書かずにアトリビュートだけで色々エディタ拡張を出来るようにしようとしているアセットです
控えめに言ってちょーべんり
Odinは良いぞ!さて、OdinにはOdinEditorWindowっていう便利な機能があるのですが、
要はEditorWindowの見た目をアトリビュートと変数だけで作ってしまえるというイケてる機能ですでもOdinもUIElementsとかで作るような凝ったものを作るのには向いていないのです。
そんなわけでその2つを組み合わせる方法をここにぺたりとおいておきます
//DefaultInspectorを追加 VisualElement root = rootVisualElement; root.Add(new IMGUIContainer(() => base.OnGUI()));以上
- 投稿日:2021-02-21T17:45:59+09:00
Unity + Oculus Integrationで躓いたこと色々
Unity + Oculus Integrationにおいて躓いたところメモ
※下書き中
■■■■■■■■■
OVR_EventSystemプレハブで作ったUI上に置いたドロップダウンの挙動がおかしい問題■事象
通常のcanvas上に置かれたUnityのドロップボックスは、以下のような挙動をします。
・ドロップダウンをクリックすると展開される
・展開されている間、ドロップダウン以外の画面のどこか(ボタンや他のドロップダウン等)をクリックしても反応せず、代わりに開いていたドロップダウンが閉じる ※つまり複数のドロップダウンが同時に開くことがないこの挙動は、以下の仕組みにより実装されています。
・開いたドロップダウンにはCanvasコンポーネントがOrder in Layer=30000で設定される(他のUIより優先される)
→これにより、開いたドロップダウンを選択したのに、ドロップダウンの裏にあるボタンが反応することが無いようになる。
・ドロップダウンを開いている間、BlockerというオブジェクトがCanvasコンポーネント(Order in Layer=29999)をもって画面全体に生成される。
・これにより、Blockerより優先されるドロップダウン以外、画面のどこをクリックしてもUIは反応せず、「開いていたドロップダウンが閉じる」という操作になる。しかしVR上のcanvas(Render Mode = Wold Space)に配置したドロップダウンでは、ドロップダウンが開いている間も他のUIが操作可能になる問題が発生しました。
開いたドロップダウンの裏にボタン等があると、ドロップダウンよりもそちらが優先されるため、まともに操作できません。
■原因
OVR_EventSystemにおける当たり判定(ray判定優先度)は、CanvasのOrder in Layerではなく、OVR Raycasterにより処理され、その優先度はOVR RaycasterのSort Order値に依存します。
このSort Order値が、通常のcanvasのOrder in Layerと同じように設定されてくれば良いのですが、どうやらすべて0で設定される様子。■対応
OVRRaycaster.csあたりを改変すればなんとかなりそうですが、アップデート時に問題が生じても嫌なのでオブジェクトの初期値で工夫して対応。・ベースとなるCanvasについているOVR RaycasterのSort Orderをマイナスに設定する(-100など)
・ドロップダウンの子オブジェクトであるTemplateに予めOVR Raycasterをアタッチしておき、Sort Orderを30000に設定しておく。(展開されたドロップダウンもSort Order = 30000で生成されるようになる。)これにより、
・開いたドロップダウン(Sort Order = 30000)が最優先で判定される
・ドロップダウンが開いている間に生成されるBlockerはデフォルト(Sort Order = 30000)なのでその次
・それ以外のUIはSort Order = -100なので、ドロップダウンが開いている間は反応しない。というように、非VR用上での動作と同じような動きになります。
■■■■■■■■■
OVRPlayerControllerの強制移動■事象
OVRPlayerControllerのpositionを動かしても移動してくれない。(地面を抜けて落ちたらy座標を指定して復帰させる場合など)■原因/対応
VRに限らずありがちなミスですが、OVRPlayerControllerにはcharactor controllerが付与されているため、無効化しないと駄目です。
落下時は、デフォルトでは終端速度もなく無制限に加速するので、ついでにFallSpeedも0.0fに戻しておきましょう。OVRPlayerController.GetComponent().enabled = false;
OVRPlayerController.transform.position = new Vector3(OVRPlayerController.transform.position.x, fukkipoint_y, OVRPlayerController.transform.position.z);
OVRPlayerController.GetComponent().enabled = true;
OVRPlayerController.GetComponent().FallSpeed = 0.0f;■■■■■■■■■
OVRPlayerControllerのlocalScaleを変えると、VR UI操作の位置がずれる■事象
VR UIの操作を、目線ではなくコントローラで操作するようにしていました。
(これはOVR_EventSystemのRay Transformと、OVRGazePointerのRay Transformにコントローラを登録するだけで実装かのうです。)そんな中、VRプレイヤーのサイズを変更して、小人や巨人になるような状況を試してみたところ、ポインタ(下図の青い丸)の表示位置がずれて見える問題が発生しました。
デフォルトでは視線で操作するため違和感が薄い(真正面なので)ですが、コントローラで操作する場合、HMDの位置とコントローラの位置が遠くなるほど違和感がひどくなります。
■原因
ポインタ(OVRGazePointer)の位置は間違っていませんでした。
このポインタ、右目用と左目用で別々のオブジェクトが用意されていて、右目用はRayが命中したcanvasより奥側、左目用は手前側に配置することで、立体的にそれっぽく見せている様子です。
プレイヤーのサイズが変わっても、この左右の視差のための補正値が変わらないため、立体視すると異常な位置に見えると思われます。■対応
これを修正するのは非常に面倒なので、独自のポインタに置きまえました。
修正するのはOVRInputModule.csのみ。変更箇所は以下だけ[public class OVRInputModule内で変数宣言]
以下を追加
public Transform MyCursor; →適当に色つけてCollider消したSphereを登録する。
public Transform vrCamera; →OVRPlayerControllerの中にあるCenterEyeAnchorを登録[virtual protected MouseState GetGazePointerData()の中]
以下の箇所でポインタの位置を指定しているので、これをコメントアウトm_Cursor.SetCursorStartDest(rayTransform.position, worldPos, normal);
同じ場所に、代わりに以下を追加
MyCursor.transform.position = worldPos;
float curScale = Vector3.Distance(MyCursor.position, vrCamera.position) * 0.03f;
if (curScale > 0.03f) curScale = 0.03f; //遠くから見た時の最大サイズ
MyCursor.localScale = new Vector3(curScale, curScale, curScale);以下のifで、rayがVR用canvasに当たっている時の処理をしている。
if (ovrRaycaster)
そのifの最後にelseを追加。(VR用canvasに当たっていない時は、ポインタを遠くに逃がしておく)
else MyCursor.transform.position = new Vector3(0.0f, 1000.0f, 0.0f);同スクリプト内には、もう1箇所「m_Cursor.SetCursorStartDest()」を呼んでいるところがあるので、そっちも改変しても良いも?(しなくても支障なく動きましたが)
・
・
- 投稿日:2021-02-21T17:45:59+09:00
Unity + Oculus Integration環境でOVR_EventSystemによるVRメニューを使っていて困ったこと色々
Unity + Oculus Integration環境で用意されているOVR_EventSystem、非VR環境用に作ったUIを、ほぼそのままVR環境にもっていける良いブレハブなのですが、いくつか躓いた事があるのでメモを残します。
※下書き中
ドロップダウンの挙動がおかしい
player(OVRPlayerController)のワープ
プレイヤーサイズ(OVRPlayerControllerのlocalScale)変更時のuiポインタ描写
VRコントローラによるVRメニューの操作をUnity Editor上でデバッグしたいドロップダウンの挙動がおかしい
OVR_EventSystemプレハブで作ったUI上に置いたドロップダウンの挙動がおかしい問題
■事象
通常のcanvas上に置かれたUnityのドロップボックスは、以下のような挙動をします。
・ドロップダウンをクリックすると展開される
・展開されている間、ドロップダウン以外の画面のどこか(ボタンや他のドロップダウン等)をクリックしても反応せず、代わりに開いていたドロップダウンが閉じる ※つまり複数のドロップダウンが同時に開くことがないしかしVR上のcanvas(Render Mode = Wold Space)に配置したドロップダウンでは、ドロップダウンが開いている間も他のUIが操作可能になる問題が発生しました。
開いたドロップダウンの裏にボタン等があると、ドロップダウンよりもそちらが優先されるため、まともに操作できません。
■原因
通常(非VR)におけるドロップダウンの挙動は、以下の仕組みにより実装されています。
・開いたドロップダウンにはCanvasコンポーネントがOrder in Layer=30000で設定されている(他のUIより優先される)
これにより、開いたドロップダウンを選択したのに、ドロップダウンの裏にあるボタンが反応することが無いようになる。
・ドロップダウンを開いている間、BlockerというオブジェクトがCanvasコンポーネント(Order in Layer=29999)をもって画面全体に生成される。
通常のUIはOrder in Layer=0なので、開いているドロップダウン以外、画面のどこをクリックしてもUIは反応せず、「開いていたドロップダウンが閉じる」という操作になる。OVR_EventSystemにおける当たり判定(ray判定優先度)は、CanvasのOrder in Layerではなく、OVR RaycasterのSort Order値に依存します。
このSort Order値が、通常のcanvasのOrder in Layerと同じように設定されてくれれば良いのですが、どうやらすべて0で設定される様子。■対応
OVRRaycaster.csあたりを改変して、OVR Raycasterの付与時にCanvasと同じようなOrderを設定するようにするのが正攻法ですが、Oculus Integrationのアップデート時に問題が生じても嫌なのでオブジェクトの初期値を工夫して対応。・ベースとなるCanvasについているOVR RaycasterのSort Orderをマイナスに設定する(-100など)
・ドロップダウンの子オブジェクトであるTemplateに予めOVR Raycasterをアタッチしておき、Sort Orderを30000に設定しておく。(展開されたドロップダウンもSort Order = 30000で生成されるようになる。)これにより、
・開いたドロップダウン(Sort Order = 30000)が最優先で判定される
・ドロップダウンが開いている間に生成されるBlockerはデフォルト(Sort Order = 0)なのでその次に優先。
・それ以外のUIはSort Order = -100なので、ドロップダウンが開いている間はBlockerに遮られて反応しない。というように、非VR環境での動作と同じように動いてくれます。
playerのワープ
OVRPlayerControllerを座標指定で強制移動させる際の問題
■事象
VRプレイヤーが地面を抜けて落ちた時、yを指定して復帰されようとOVRPlayerControllerのpositionを動かしたが効果がない。
(メニュー操作と関係ない問題ですね、、)■原因/対応
VRに限らずありがちなミスですが、OVRPlayerControllerにはcharactor controllerが付与されているため、無効化しないと駄目です。
落下時から復帰する場合、デフォルトでは無制限に落下加速するので、ついでにFallSpeedも0.0fに戻しておきましょう。OVRPlayerController.GetComponent<CharacterController>().enabled = false; OVRPlayerController.transform.position = new Vector3(OVRPlayerController.transform.position.x, fukkipoint_y, OVRPlayerController.transform.position.z); OVRPlayerController.GetComponent<CharacterController>().enabled = true; OVRPlayerController.GetComponent<OVRPlayerController>().FallSpeed = 0.0f;プレイヤーサイズ変更時のuiポインタ描写
OVRPlayerControllerのlocalScaleを変えると、VR UI操作の位置がずれる
■事象
VR UIの操作を、目線ではなくコントローラで操作するようにしていました。
(これはOVR_EventSystemのRay Transformと、OVRGazePointerのRay Transformにコントローラを登録するだけで実装できます。)そんな中、VRプレイヤーのサイズを変更して、小人や巨人になるような状況を試してみたところ、ポインタ(下図の青い丸)の表示位置がずれて見える問題が発生しました。
デフォルトでは視線で操作するため違和感が薄い(真正面なので)ですが、コントローラで操作する場合、HMDの位置とコントローラの位置が遠くなるほど違和感がひどくなります。
■原因
ポインタ(OVRGazePointer)の位置は間違っていませんでした。
このポインタ、右目用と左目用で別々のオブジェクトが用意されていて、右目用はRayが命中したcanvas座標より奥側、左目用は手前側に配置することで、立体的にそれっぽく見せている様子です。
プレイヤーのサイズが変わっても、この左右の視差のための補正値が変わらないため、立体視すると異常な位置に見えると思われます。■対応
これを修正するのは非常に面倒なので、独自のポインタオブジェクトに変えました。ポインタオブジェクトとして、あらかじめ適当に色つけてCollider消したSphereをHierarchy上に作っておきます。
修正するのはOVRInputModule.csのみ。変更箇所は以下だけ
[public class OVRInputModule内で変数宣言]
以下を追加public Transform MyCursor; //作ったSphereを登録する。 public Transform vrCamera; //OVRPlayerControllerの中にあるCenterEyeAnchorを登録[virtual protected MouseState GetGazePointerData()の中]
以下の箇所でポインタの位置を指定しているので、これをコメントアウトm_Cursor.SetCursorStartDest(rayTransform.position, worldPos, normal);
同じ場所に、代わりに以下を追加
MyCursor.transform.position = worldPos; float curScale = Vector3.Distance(MyCursor.position, vrCamera.position) * 0.03f; if (curScale > 0.03f) curScale = 0.03f; //遠くから見た時の最大サイズ MyCursor.localScale = new Vector3(curScale, curScale, curScale);以下のifで、rayがVR用canvasに当たっている時の処理をしている。
if (ovrRaycaster)
そのifの最後にelseを追加。(VR用canvasに当たっていない時は、ポインタを遠くに逃がしておく)
else MyCursor.transform.position = new Vector3(0.0f, 1000.0f, 0.0f);要は、操作するメニュー画面が大きくても小さくても、遠くても近くても操作できるように以下の仕様にしています。
・rayが当たった位置にポインタオブジェクトを配置
・rayがメニューにあたっていない間はポインタは表示しない。
・ポインタオブジェクトの大きさはカメラとの距離に依存させる(遠くても近くても同じ大きさに見えるように)
・ポインタオブジェクトの最大サイズは決めておく(非常に遠くからコントローラをメニューに向けた時のため)同スクリプト内には、もう1箇所「m_Cursor.SetCursorStartDest()」を呼んでいるところがあるので、そっちも改変しても良いも?(しなくても支障ありませんでしたが)
メニューの操作をunity editor上でデバッグ
VRコントローラによるVRメニューの操作をUnity Editor上でデバッグしたい。
■事象
ちょっとしたメニュー操作のデバッグのためにquestをかぶりたくない。
あるいは作業環境が悪くてoculus link使えない。(ビルドしないとデバッグできない)
そんな時に、キーボードからメニューを操作してデバッグする。■対応
OVRInputModule.csの以下の箇所を、var pressed = Input.GetKeyDown(gazeClickKey) || OVRInput.GetDown(joyPadClickButton);
var released = Input.GetKeyUp(gazeClickKey) || OVRInput.GetUp(joyPadClickButton);以下のようにtキーでも反応するように変更するだけ。
var pressed = Input.GetKeyDown(gazeClickKey) || OVRInput.GetDown(joyPadClickButton) || Input.GetKeyDown("t"); var released = Input.GetKeyUp(gazeClickKey) || OVRInput.GetUp(joyPadClickButton) || Input.GetKeyUp("t");Rayを発射するオブジェクト(コントローラ等)をSceneビューで動かしてtキーを押せば、VR環境での操作にかなり近い状況でのデバッグができます。
■■■■■■■■■
- 投稿日:2021-02-21T14:13:45+09:00
[Unity] C# JobSystem を利用してテキストファイルを非同期でパースする
一定時間ごとにある程度の大きさのテキストファイルを読み込んで、その内容を反映させるプロジェクトのため、Unity(C#)で使える高速なファイルアクセスAPIを調べて
AsyncReadManagerに行きつきました。偉大なる先駆者様 :
【Unity】ファイルを非同期で読み込んでアンマネージドメモリに展開できるAsyncReadManagerを試してみたこの先行研究ではメインスレッド上での動作確認とメモリ負荷の検証が主で、本文でさらっと
アンマネージドメモリにデータをキャッシュすると言いつつも、実装としてはJob等で並列化せずに愚直にメインスレッド上でパースを行っております。。(もう少し工夫すればJob化出来そうな気がしなくもなく。。要検証)
との表記に誘われて四苦八苦した結果、どうにか動くようになりましたので紹介させていただきます。
Unityプロジェクトなのに外部ファイル(しかもテキスト)をたくさん使うとか、非常にニッチな需要だとは思いますがそんな誰かの役にも立てばいいな……Unityアドカレに1個未投稿の枠があったので、今更ながら滑り込ませていただきました。
成果物
C# JobSystem で文字列の類をいい感じに扱うツールセットは最終的に以下のようになりました。
GitHub
NativeStringCollections動作環境
- Unity 2019.4.20f1
- Collections 0.9.0-preview.6
使い方
雰囲気はこんな感じ。
using NativeStringCollections public class TextData : ITextFileParser { NativeList<DataElement> Data; public void Init() { /* クラス初期化。 new() 後に一度だけ呼ばれる */ /* ここだけはメインスレッドで実行されるのでマネージ型を使ってもいい */ } public void Clear() { /* パース準備。 ParseLine(line) が始まる前に一度呼ばれる */ } public bool ParseLine(ReadOnlyStringEntity line) { /* line を解析する。 次の行も読みたいなら true を返す */ } public void PostProc() { /* 後処理。 ParseLine(line) が終わったら一度呼ばれる */ } public void UnLoad() { /* 一時的にデータを破棄したいときにここに処理を書く */ } } public class Hoge : MonoBehaviour { AsyncTextFileReader<TextData> reader; void Start() { reader = new AsyncTextFileReader<TextData>(Allocator.Persistent); } void Update() { // どこかでファイル読み込みの指示を出す (必要なら Encoding も指定する) reader.Encoding = Encoding.UTF8; reader.LoadFile(path); // 進捗を表示できる (Read, Length ともに BlockSize単位のint) var info = reader.GetState float progress = (float)info.Read / info.Length; // 終わってたら Complete() if(reader.JobState == ReadJobState.WaitForCallingComplete) { reader.Complete(); // 読み込みにかかった時間も出せる [ms] double delay = reader.GetState.Delay; Debug.Log($" file loading completed. time = {delay.ToString("F2")} [ms]."); // データを取り出して何かする var data = reader.Data; } } void OnDestroy() { // データのDispose() は外で行う。 reader だけ先に Dispose() してもよい var data = reader.Data; reader.Dispose(); data.Dispose(); } }文字列の具体的な変換はこんな感じ
using NativeStringCollections; public class TextData : ITextFileParser { public NativeList<DataElement> Data; private NativeStringList mark_list; private StringEntity check_mark; // パース中に string を使いたい場合は // Init() 内で NativeStringList や NativeList<char> に格納しておく public void Init() { Data = new NativeList<DataElement>(Allocator.Persistent); mark_list = new NativeStringList(Allocator.Persistent); mark_list.Add("STRONG"); mark_list.Add("Normal") // NativeStringList から StringEntity を取り出すのは値を全て格納してから // あるいは全ての文字列を格納しきれるようあらかじめ大きな Capacity を設定しておく // (StringEntity を取り出した後にバッファが再確保されると不正メモリ参照でクラッシュ) check_mark = mark_list[0]; } // 改行コードはあらかじめ解析されて行ごとに入力される public bool ParseLine(ReadOnlyStringEntity line) { // StringEntity.Split() の結果を受け取るリスト var str_list = new NativeList<ReadOnlyStringEntity>(Allocator.Temp); // カンマ区切りで "CharaName_STRONG,11,64,15.7,1.295e+3" みたいなデータだったなら line.split(',', str_list); // こんな風に var name = str_list[0]; bool success = true; success = success && str_list[1].TryParse(out long ID); success = success && str_list[2].TryParse(out int HP); success = success && str_list[3].TryParse(out float Attack); success = success && str_list[4].TryParse(out double Speed); // こんなことも var chara_tag = name.Slice(10, 16); // "STRONG" を抽出 if(chara_tag == check_mark) { /* このキャラ特有の何か */ } str_list.Dispose() // 正しいフォーマットとして解析できたか if(!success) return false; // 解釈できなかったのでパース中止 Data.Add(new DataElement(ID, HP, Attack, Speed)); return true; // 解釈できたので次の行に進む } }このような具合に。
実際には JobSystem の中で処理されますが、ユーザーが書く部分は通常のC#にだいぶ近い感じに設計できたと思います。また、上記
class TextDataは各プロジェクトにおいて適宜差し替えて使用することが前提ですが、常に JobSystem で実行されるとデバッグが面倒です。なのでメインスレッドでの実行を強制する下記のAPIもあります。var reader = new AsyncTextFileReader<NewProjectData>(Allocator.Persistent); reader.Encoding = Encoding.UTF8; reader.Path = path; // この場合 ParseLine(line) 内で Debug.Log() が使える。 // また、 var sb = new StringBuilder() や (obj).ToString() をしてもよい。 reader.LoadFileInMainThread(); if(reader.JobState == ReadJobState.WaitForCallingComplete) reader.Complete(); // データを取り出してデバッグする var data = reader.Data; reader.Dispose();ちなみに、先駆者様の例と同等のサイズの 50万キャラクターのファイルについて、当方の環境では
処理内容 経過時間 CharaData.ToString() をループで回してファイル出力 1100~1200ms CharaDataParser.ParseLine(line)で解析、格納 600~700ms となっており、 C# string と高速化の相性の悪さが如実に表れています。
(ファイル出力側は最適化について何も考えていないとはいえ……)とりあえず、これで
File.ReadAllLines()を使わずに済むようになるでしょう。中身のお話 (あるいは四苦八苦の記録)
string 使用禁止! しかし Encoding や Parse() 、 Split() は欲しい……
C# string と JobSystem の相克
C# における文字列解析、というと、よくある例としては
Files.ReadAllLines()で string[] を受け取り、イテレータで行ごとに回してそこから望みのフォーマットにsplit()で切り出した後、数値に変換するならParse()メソッドを使用する、というパターンかと思います。
しかしこのデザインの根幹であるstringは参照型で、たとえGCHandleなどを使用し JobSystem に持ち込んでも string インスタンスの生成は当然できないのでString.Split()が使えません。
そこで本実装では Unity 2019.1 より char 型の NativeContainer を作成できるようになった ことを利用して、文字列はまるっとNativeList<char>に保持して、これにstringのように扱えるインターフェイスを被せることにしました。
まず文字列全体の管理として、string(のようなもの)が集合したcharについてのジャグ配列 に相当するコンテナにデータ本体を保持し、外側配列のインデックスアクセスで当該部分のスライスを取り出す、という形にします。List<string>のように使えることを目標とします。
大本の管理 struct は何となくジェネリックにします。
ジェネリックなデータ本体部
NativeJaggedArray<T>の実装(抜粋)NativeJaggedArray.cspublic struct NativeJaggedArray<T> : IDisposable, IEnumerable<NativeJaggedArraySlice<T>> where T : unmanaged, IEquatable<T> { internal struct ElemIndex { public int Start { get; private set; } public int Length { get; private set; } public int End { get { return this.Start + this.Length; } } public ElemIndex(int st, int len) { this.Start = st; this.Length = len; } } private NativeList<T> _buff; private NativeList<ElemIndex> _elemIndexList; #if ENABLE_UNITY_COLLECTIONS_CHECKS private NativeArray<long> genTrace; private PtrHandle<long> genSignature; #endif public unsafe NativeJaggedArray(Allocator alloc) { _buff = new NativeList<T>(alloc); _elemIndexList = new NativeList<ElemIndex>(alloc); _alloc = alloc; #if ENABLE_UNITY_COLLECTIONS_CHECKS genTrace = new NativeArray<long>(1, alloc); genSignature = new PtrHandle<long>((long)_buff.GetUnsafePtr(), alloc); // sigunature = address value of ptr for char_arr. #endif } public void Clear() { this._buff.Clear(); this._elemIndexList.Clear(); } public int Length { get { return this._elemIndexList.Length; } } public int Size { get { return this._buff.Length; } } public unsafe NativeJaggedArraySlice<T> this[int index] { get { var elem_index = this._elemIndexList[index]; T* elem_ptr = (T*)this._buff.GetUnsafePtr() + elem_index.Start; #if ENABLE_UNITY_COLLECTIONS_CHECKS return new NativeJaggedArraySlice<T>(elem_ptr, elem_index.Length, this.GetGenPtr(), this.GetGen()); #else return new NativeJaggedArraySlice<T>(elem_ptr, elem_index.Length); #endif } } public unsafe void Add(T* ptr, int Length) { int Start = this._buff.Length; this._buff.AddRange((void*)ptr, Length); this._elemIndexList.Add(new ElemIndex(Start, Length)); this.UpdateSignature(); } /// <summary> /// specialize for NativeJaggedArraySlice<T> /// </summary> /// <param name="slice"></param> public unsafe void Add(NativeJaggedArraySlice<T> slice) { this.Add((T*)slice.GetUnsafePtr(), slice.Length); } public void RemoveAt(int index) { this.CheckElemIndex(index); for (int i = index; i < this.Length - 1; i++) { this._elemIndexList[i] = this._elemIndexList[i + 1]; } this._elemIndexList.RemoveAtSwapBack(this.Length - 1); } #if ENABLE_UNITY_COLLECTIONS_CHECKS if (gap > 0) this.NextGen(); #endif } [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")] unsafe private void UpdateSignature() { #if ENABLE_UNITY_COLLECTIONS_CHECKS long now_sig = GetGenSigneture(); if (now_sig != this.genSignature) { this.NextGen(); this.genSignature.Value = now_sig; } #endif } #if ENABLE_UNITY_COLLECTIONS_CHECKS private void NextGen() { long now_gen = this.genTrace[0]; this.genTrace[0] = now_gen + 1; } private unsafe long GetGenSigneture() { return (long)this._buff.GetUnsafePtr(); } private long GetGen() { return this.genTrace[0]; } unsafe private long* GetGenPtr() { return (long*)this.genTrace.GetUnsafePtr(); } #endif }
ジェネリックなスライス部分
NativeJaggedArraySlice<T>の実装(抜粋)NativeJaggedArraySlice.csunsafe public interface IJaggedArraySliceBase<T> where T: unmanaged, IEquatable<T> { int Length { get; } T this[int index] { get; } bool Equals(NativeJaggedArraySlice<T> slice); bool Equals(ReadOnlyNativeJaggedArraySlice<T> slice); bool Equals(T* ptr, int Length); void* GetUnsafePtr(); } public interface ISlice<T> { T Slice(int begin = -1, int end = -1); } [StructLayout(LayoutKind.Sequential)] public readonly unsafe struct NativeJaggedArraySlice<T> : IJaggedArraySliceBase<T>, IEnumerable<T>, IEquatable<IEnumerable<T>>, IEquatable<T>, ISlice<NativeJaggedArraySlice<T>> where T: unmanaged, IEquatable<T> { [NativeDisableUnsafePtrRestriction] internal readonly T* _ptr; internal readonly int _len; public int Length { get { return _len; } } #if ENABLE_UNITY_COLLECTIONS_CHECKS [NativeDisableUnsafePtrRestriction] internal readonly long* _gen_ptr; internal readonly long _gen_entity; #endif #if ENABLE_UNITY_COLLECTIONS_CHECKS public NativeJaggedArraySlice(T* ptr, int Length, long* gen_ptr, long gen_entity) { _ptr = ptr; _len = Length; _gen_ptr = gen_ptr; _gen_entity = gen_entity; } #else public NativeJaggedArraySlice(T* ptr, int Length) { _ptr = ptr; _len = Length; } #endif public T this[int index] { get { this.CheckReallocate(); return *(_ptr + index); } set { this.CheckReallocate(); this.CheckElemIndex(index); *(_ptr + index) = value; } } public NativeJaggedArraySlice<T> Slice(int begin = -1, int end = -1) { if (begin < 0) begin = 0; if (end < 0) end = _len; this.CheckSliceRange(begin, end); int new_len = end - begin; #if ENABLE_UNITY_COLLECTIONS_CHECKS this.CheckReallocate(); return new NativeJaggedArraySlice<T>(_ptr + begin, new_len, _gen_ptr, _gen_entity); #else return new NativeJaggedArraySlice<T>(_ptr + begin, new_len); #endif } [Conditional("ENABLE_UNITY_COLLECTIONS_CHECKS")] private void CheckReallocate() { #if ENABLE_UNITY_COLLECTIONS_CHECKS if(_gen_ptr == null && _gen_entity == -1) return; // ignore case for NativeJaggedArraySliceGeneratorExt if( *(_gen_ptr) != _gen_entity) { throw new InvalidOperationException("this slice is invalid reference."); } #endif } public void* GetUnsafePtr() { return _ptr; } }スライスは本当にポインタと長さしか持ちません (release build 時)。
Unity.CollectionsでNativeArray<T>からNativeSlice<T>は作れるのに対してNativeList<T>からは作れないのは、 List の伸縮に伴い内部バッファの再確保が行われた場合、メモリ上の位置が変わってそれまでに作ったポインタによる参照が無効になってしまうため、安全なスライスを作れないことが理由の一つとして考えられます。
今回の実装では、文字列の取り扱いとして最初にデータ全体の構築を行い、その後は要素の追加をせずにスライスの切り出しのみを行うことを基本方針としてちょっと危なめな設計にしました。
そうはいってもついやっちゃうこともあり得るので、要素の追加時に内部バッファの_buff.GetUnsafePtr()の値が変化したかどうかを確認し、それにより世代確認を行う関数CheckReallocate()を実装してあります。
UnityEditor 上であればやらかしを検知できます。これに文字列に特化したインターフェイスを被せてジャグ配列の
NativeStringListとスライスのStringEntityとします。(今更だけど怒られそうな名前を付けてしまった……)
NativeStringListの実装(抜粋)NativeStringList.cspublic struct NativeStringList : IDisposable, IEnumerable<StringEntity> { private NativeJaggedArray<char> _jarr; public unsafe NativeStringList(Allocator alloc) { _jarr = new NativeJaggedArray<char>(alloc); } public void Dispose() { _jarr.Dispose(); } public unsafe StringEntity this[int index] { get; } public StringEntity Last { get; } // string のようなものへの特殊化 public void Add(IEnumerable<char> str) { _jarr.Add(str); } public unsafe void Add(char* ptr, int Length) { _jarr.Add(ptr, Length); } public unsafe void Add(StringEntity entity) { this.Add((char*)entity.GetUnsafePtr(), entity.Length); } public unsafe void Add(ReadOnlyStringEntity entity) { this.Add((char*)entity.GetUnsafePtr(), entity.Length); } public unsafe void Add(NativeList<char> str) { this.Add((char*)str.GetUnsafePtr(), str.Length); } public unsafe void Add(NativeArray<char> str) { this.Add((char*)str.GetUnsafePtr(), str.Length); } }
StringEntityの実装(抜粋)StringEntity.cspublic unsafe readonly struct StringEntity : IParseExt, IJaggedArraySliceBase<char>, ISlice<StringEntity>, IEquatable<string>, IEquatable<char[]>, IEquatable<IEnumerable<char>>, IEquatable<char>, IEnumerable<char> { /* 中略 */ /* string のようなものへの特殊化 */ public bool Equals(char* ptr, int Length) { this.CheckReallocate(); if (_len != Length) return false; // pointing same target if (_ptr == ptr) return true; for (int i = 0; i < _len; i++) { if (_ptr[i] != ptr[i]) return false; } return true; } public bool Equals(StringEntity entity) { this.CheckReallocate(); return entity.Equals(_ptr, _len); } public bool Equals(ReadOnlyStringEntity entity) { this.CheckReallocate(); return entity.Equals(_ptr, _len); } public bool Equals(NativeJaggedArraySlice<char> slice) { this.CheckReallocate(); return slice.Equals(_ptr, _len); } public bool Equals(ReadOnlyNativeJaggedArraySlice<char> slice) { this.CheckReallocate(); return slice.Equals(_ptr, _len); } public bool Equals(string str) { if (this.Length != str.Length) return false; return this.SequenceEqual<char>(str); } public bool Equals(char[] c_arr) { if (this.Length != c_arr.Length) return false; return this.SequenceEqual<char>(c_arr); } public bool Equals(char c) { return (this.Length == 1 && this[0] == c); } public bool Equals(IEnumerable<char> in_itr) { this.CheckReallocate(); return this.SequenceEqual<char>(in_itr); } public static bool operator ==(StringEntity lhs, StringEntity rhs) { return lhs.Equals(rhs); } public static bool operator !=(StringEntity lhs, StringEntity rhs) { return !lhs.Equals(rhs); } public static bool operator ==(StringEntity lhs, ReadOnlyStringEntity rhs) { return lhs.Equals(rhs); } public static bool operator !=(StringEntity lhs, ReadOnlyStringEntity rhs) { return !lhs.Equals(rhs); } public static bool operator ==(StringEntity lhs, IEnumerable<char> rhs) { return lhs.Equals(rhs); } public static bool operator !=(StringEntity lhs, IEnumerable<char> rhs) { return !lhs.Equals(rhs); } public override bool Equals(object obj) { return obj is StringEntity && ((IJaggedArraySliceBase<char>)obj).Equals(_ptr, _len); } public ReadOnlyStringEntity GetReadOnly() { return new ReadOnlyStringEntity(this); } public void* GetUnsafePtr() { return _ptr; } }これで
stringのようなもの同士で比較したり、スライスを切り出したりやりたい放題できるようになりました。
なお、NativeJaggedArray<T>のほうを使えば任意のstructについてユーザー管理の共通バッファへの参照を JobSystem と GameObject の両方にばらまくことができてしまいます。ポインタ無法地帯へはあと一歩のぎりぎりのラインにいるので、ご利用は計画的に。Encoder の自力実装は勘弁してほしい
- なので
GCHandleで JobSystem の中に持っていく
Encoder,Decoderをバグなく実装する自信はないですし、さらに日本語の文字コードは Unicode系列 (UTF-8, UTF-16, UTF-32)のほかに Shift-JISやらEUC-JP、 ISO-2022-JPなどどんなデータを読む羽目になるか分かったものではありません。(特に古いシステムの吐いたデータほど。)
幸い C# 標準にDecoder.GetChars(byte*, int, char* ,int)関数が用意されており、GCHandleで持ち込みさえすれば JobSystem で使えます。- 日本ローカルの Encoding に注意!
上で上げたエンコーディングのうち、Shift-JIS、EUC-JP、ISO-2022-JP の3つは UnityEditor上では普通に使えますがビルドすると必要なDLLが欠けるためプレイヤーがこけます。
上の記事で対応は可能ですが、レガシーの文字エンコードの対応が適当……
(いやゲームエンジンとしては不要なモノなのでまっとうな設計ではあるのですが)TryParse(), Split(), Strip() は自力実装
文字列解析用データ構造として
StringEntityを自作してしまったので、これらのユーティリティも当然自作します。
実装の単純化のため、C#ではParse()メソッドで一緒くたになっていた十進表記と16進数表記の解析をTryParse()とTryParseHex()に分離します。パーサーを作るにあたって、どちらの表記かわからない、なんてことはないでしょうし、そもそもHexフォーマットを使う状況というのはfloatやdoubleの値を確実に読み書きしたい状況ぐらいでしょう。
Split(),Strip()については、さっそくStringEntity.Slice()の出番です。普通に線形探索して結果を切り出します。Base64 の変換もできると便利
前節で
TryParseHex()を用意したものの、データの利用効率が劣悪(4bit -> 8bit と必要分で単純に倍、プリフィックスに0xをつければ 2B 追加。ASCIIコード換算でfloatが 4B -> 10B = 250% になる)なので配列や構造体の生バイト列などの大きなものには正直向いていません。
というわけで由緒正しき Base64 のコンバータを用意しましょう。
C# Reference Source の実装を参考に、テーブル変換なので中身は単純です。
Base64コンバータの実装(抜粋)
StringParser.cs/// <summary> /// The Encoder for MIME Base64 (RFC 2045). /// </summary> public struct NativeBase64Encoder : IDisposable { private Base64EncodeMap _map; private PtrHandle<Base64Info> _info; /// <summary> /// convert bytes into chars in Base64 format. /// </summary> /// <param name="buff">output</param> /// <param name="byte_ptr">source ptr</param> /// <param name="byte_len">source length</param> /// <param name="splitData">additional bytes will be input or not. (false: call Terminate() internally.</param> public unsafe void GetChars(NativeList<char> buff, byte* byte_ptr, int byte_len, bool splitData = false) { if (byte_len < 0) throw new ArgumentOutOfRangeException("invalid bytes length."); uint store = _info.Target->store; int bytePos = _info.Target->bytePos; int charcount = 0; for(uint i=0; i<byte_len; i++) { if (_info.Target->insertLF) { if (charcount == Base64Const.LineBreakPos) { buff.Add('\r'); buff.Add('\n'); charcount = 0; } } store = (store << 8) | byte_ptr[i]; bytePos++; // encoding 3 bytes -> 4 chars if(bytePos == 3) { buff.Add(_map[(store & 0xfc0000) >> 18]); buff.Add(_map[(store & 0x03f000) >> 12]); buff.Add(_map[(store & 0x000fc0) >> 6]); buff.Add(_map[(store & 0x00003f)]); charcount += 4; store = 0; bytePos = 0; } } _info.Target->store = store; _info.Target->bytePos = bytePos; if (!splitData) this.Terminate(buff); } /// <summary> /// apply termination treatment. /// </summary> /// <param name="buff">output</param> public unsafe void Terminate(NativeList<char> buff) { uint tmp = _info.Target->store; switch (_info.Target->bytePos) { case 0: // do nothing break; case 1: // two character padding needed buff.Add(_map[(tmp & 0xfc) >> 2]); buff.Add(_map[(tmp & 0x03) << 4]); buff.Add(_map[64]); // pad buff.Add(_map[64]); // pad break; case 2: // one character padding needed buff.Add(_map[(tmp & 0xfc00) >> 10]); buff.Add(_map[(tmp & 0x03f0) >> 4]); buff.Add(_map[(tmp & 0x000f) << 2]); buff.Add(_map[64]); // pad break; } _info.Target->store = 0; _info.Target->bytePos = 0; } public void Dispose() { _map.Dispose(); _info.Dispose(); } } /// <summary> /// The Decoder for MIME Base64 (RFC 2045). /// </summary> public struct NativeBase64Decoder : IDisposable { private Base64DecodeMap _map; private PtrHandle<Base64Info> _info; /// <summary> /// convert Base64 format chars into bytes. /// </summary> /// <param name="buff">output</param> /// <param name="char_ptr">source ptr</param> /// <param name="char_len">source length</param> /// <returns>convert successfull or not</returns> public unsafe bool GetBytes(NativeList<byte> buff, char* char_ptr, int char_len) { if (char_len < 0) { #if UNITY_EDITOR throw new ArgumentOutOfRangeException("invalid chars length."); #else return false; #endif } uint store = _info.Target->store; int bytePos = _info.Target->bytePos; for(int i=0; i<char_len; i++) { char c = char_ptr[i]; if (this.IsWhiteSpace(c)) continue; if(c == '=') { switch (bytePos) { case 0: case 1: #if UNITY_EDITOR throw new ArgumentException("invalid padding detected."); #else return false; #endif case 2: // pick 1 byte from "**==" code buff.Add((byte)((store & 0x0ff0) >> 4)); bytePos = 0; break; case 3: // pick 2 byte from "***=" code buff.Add((byte)((store & 0x03fc00) >> 10)); buff.Add((byte)((store & 0x0003fc) >> 2)); bytePos = 0; break; } return true; } else { uint b = _map[c]; if (b != 255) { store = (store << 6) | (b & 0x3f); bytePos++; } } if(bytePos == 4) { buff.Add((byte)((store & 0xff0000) >> 16)); buff.Add((byte)((store & 0x00ff00) >> 8)); buff.Add((byte)((store & 0x0000ff))); store = 0; bytePos = 0; } } _info.Target->store = store; _info.Target->bytePos = bytePos; return true; } private bool IsWhiteSpace(char c) { return (c == ' ' || c == '\t' || c == '\n' || c == '\r'); } } internal struct Base64EncodeMap : IDisposable { private NativeArray<byte> _map; public Base64EncodeMap(Allocator alloc) { _map = new NativeArray<byte>(65, alloc); int i = 0; for(byte j=65; j<=90; j++) // 'A' ~ 'Z' { _map[i] = j; i++; } for(byte j=97; j<=122; j++) // 'a' ~ 'z' { _map[i] = j; i++; } for(byte j=48; j<=57; j++) // '0' ~ '9' { _map[i] = j; i++; } _map[i] = 43; i++; // '+' _map[i] = 47; i++; // '/' _map[i] = 61; // '=' } public char this[uint index] { get { if (index > 65) throw new ArgumentOutOfRangeException("input byte must be in range [0x00, 0x40]."); return (char)_map[(int)index]; } } } internal struct Base64DecodeMap : IDisposable { private NativeArray<byte> _map; public Base64DecodeMap(Allocator alloc) { _map = new NativeArray<byte>(80, alloc); int i = 0; _map[i] = 62; i++; // 0x2b, '+' for(int j=0; j<3; j++) { _map[i] = 255; i++; // invalid code } _map[i] = 63; i++; // 0x2f, '/' for(byte j=52; j<=61; j++) { _map[i] = j; i++; // '0' ~ '9' } for(byte j=0; j<7; j++) { _map[i] = 255; i++; // invalid code } for(byte j=0; j<=25; j++) { _map[i] = j; i++; // 'A' ~ 'Z' } for(byte j=0; j<6; j++) { _map[i] = 255; i++; // invalid code } for (byte j = 26; j <= 51; j++) { _map[i] = j; i++; // 'a' ~ 'z' } } public byte this[uint index] { get { if (index < 0x2b) return 255; if (index > 0x7a) return 255; return _map[(int)(index - 0x2b)]; } } }元のリファレンスでは全ビットパターン分(16*16=256)テーブルが作ってありましたが、有効な値は64種類、いくつか途中にある無効な値を考慮しても端から端まで長さ80あれば足りるので、今回の実装ではせっかくなので小さくしてみました。
どうせならユーザー定義データも class にしてしまえばいい
Encodingを持ち込むと決めた段階で、byte列 -> char列の変換処理に Burst を使えないことが確定しました。
ジョブ丸ごとの struct 化とかもう気にしなくていいので、ユーザー定義のデータコンテナも class ということにして、これもGCHandleでJobSystemへ持って行きます。
ただしこの設計により、誤って struct のデータコンテナを渡したところ当然ながら GCHandle の取得でコケたので、最終的に class のみを受け取る形にしました。ポインタがすべてを解決する
実はこの段階のパース速度は Chara 50万体のデータに ~ 1300 ms 程度とだいぶ遅かったのですが、プロファイラを確認したところ主要な処理負荷がインデクサ
this[index]やLengthフィールドから値を取り出すところだったので、ライブラリ内の処理実装部分では最初にvoid*とLengthを取り出してポインタで直接処理するようにしました。その高速化の経過は下記の通り。
処置した関数 速度 StringSplitter.Split() ~1000 ms 上記に加え、 TextDecoder.ParseLineImpl() + NativeStringList を ref 渡し 700 ~ 800 ms 上記に加え、 StringParserExt.TryParse() 600 ~ 700 ms 見やすいプロファイラは素晴らしい。
また、欲しい関数の追加や処理のバッファとして内部的に使う、などにより
NativeContinerに似たものを多数作成しましたが、状態管理用の変数ごとにポインタを作るのは手間がかかる上に事故の危険もコピーコストも増大する挙句、データがメモリ上に分散して性能に悪影響を及ぼします。
よって、ジェネリックなポインタ管理ヘルパーPtrHandle<T>を作りましょう。
ポインタ管理ヘルパー
PtrHandle<T>の実装PtrHandle.csnamespace NativeStringCollections.Utility { public unsafe struct PtrHandle<T> : IDisposable where T : unmanaged { [NativeDisableUnsafePtrRestriction] private T* _ptr; private Allocator _alloc; private Boolean _isCreated; public PtrHandle(Allocator alloc) { if (alloc <= Allocator.None) throw new ArgumentException("Allocator must be Temp, TempJob or Persistent", nameof(alloc)); _alloc = alloc; _ptr = (T*)UnsafeUtility.Malloc(UnsafeUtility.SizeOf<T>(), UnsafeUtility.AlignOf<T>(), _alloc); _isCreated = true; } public PtrHandle(T value, Allocator alloc) { if (alloc <= Allocator.None) throw new ArgumentException("Allocator must be Temp, TempJob or Persistent", nameof(alloc)); _alloc = alloc; _ptr = (T*)UnsafeUtility.Malloc(UnsafeUtility.SizeOf<T>(), UnsafeUtility.AlignOf<T>(), _alloc); _isCreated = true; *_ptr = value; } public Boolean IsCreated { get { return (_isCreated); } } public void Create() { _ptr = (T*)UnsafeUtility.Malloc(UnsafeUtility.SizeOf<T>(), UnsafeUtility.AlignOf<T>(), _alloc); _isCreated = true; } public void Dispose() { if (IsCreated) { this.CheckAllocator(); UnsafeUtility.Free((void*)_ptr, _alloc); _ptr = null; _isCreated = false; } else { throw new InvalidOperationException("Dispose() was called twise, or not initialized target."); } } public T* Target { get { if (!_isCreated) throw new InvalidOperationException("target is not allocated. call Create()."); return _ptr; } } public T Value { set { *_ptr = value; } get { return *_ptr; } } public static implicit operator T(PtrHandle<T> value) { return *value._ptr; } private void CheckAllocator() { if (!UnsafeUtility.IsValidAllocator(_alloc)) throw new InvalidOperationException("The buffer can not be Disposed because it was not allocated with a valid allocator."); } } }この
PtrHandle<T>を使用して、NativeContinerに似た struct の状態変数は以下のように一括管理ができるようになります。struct MyInfo { public int Size; public bool Flag; public MyStateEnum State; } struct MyProcessor<T> : IDisposable where T: unmanaged { private NativeList<T> _buffer; private PtrHandle<MyInfo> _info; public MyProcessor(Allocator alloc) { _buffer = new NativeList<T>(alloc); _info = new PtrHandle<T>(alloc); } public unsafe void Execute() { if(_info.Target->State == MyStateEnum.Default) { /* 何か処理 */ } } }また、これも GameObject 側と JobSystem 側での変数の共有に使えるので、(外部に渡す場合には内容を別の出力専用
readonly structにコピーしてから返すなどの安全対策をしておけば) Job の管理にも有用です。
デモでファイル読み込みの進捗状況を取得して表示させていますが、内部的にはPtrHandle<T>を利用しています。すでに
NativeArrayの運用に習熟していらっしゃる皆様もお気づきのことでしょうが、実は上記の「あるunmanaged structをポインタ経由で管理したい」という状況は、サイズ1のNativeArrayでも代用できなくはありません。しかし、UnityEditor上でのみではありますが、デバッグ用の安全装置の存在のためNativeContainerはマネージド型変数を持っており、NativeArray<NativeArray<T>>のようなものは作れません。またNativeContainerはインデクサアクセスでは保持しているstructのコピーを出し入れする形となり、状態変数の取り扱いは書きやすいとは言えません。
以上、安全装置のないPtrHandle<T>を使ってしまった言い訳でしたが、大抵は別のNativeContainerとセットで運用しているはずなので、AllocateもDispose()もセットで書いてやれば大丈夫です。
そんなこと言いつつ昨日メモリリーク1ヵ所見つけましたごめんなさいライブラリに閉じ込めるなどしてポインタが暴れださないようにできれば、ポインタはすべてを解決する。
in readonly struct で速くな……らない!
実はしれっとスライスの実装を
readonly structで定義していたので、さらなるコピー削減のためStringEntityを引数に渡している部分にinをつけてみます。その結果……100 ms 程遅くなりました。
そもそも最初に頑張ってスライスを軽量化した結果、パディングの具合にもよりますがリリースビルドでは 8 byte ~ 16 byte のフットプリントしかありません。小さい struct では in readonly struct を渡して低速化する報告もあり、常々言われることではありますがやはり最適化に計測と確認は必須です。文字列デコードのブロック処理
C# における
charは最小データサイズが 2 byte になる UTF-16 が採用されています。ここで、例えば UTF-8 でエンコードされた、ほぼASCIIコードのテキストファイル(=ファイル上ではほぼ全て 1 byte)を一気にデコードすると、メモリ上にファイルサイズの倍の大きさの char配列が出現します。元のファイルサイズがMBクラスの大きさならあっという間にCPUのキャッシュからはみ出して処理速度が悲しいことになります。
というわけで、キャッシュ内でパース処理をするためにブロック単位で char に変換し、改行コードを解析して line を示すスライスに切り出し、ブロック内の切り出しが終わったら出来上がった line をITextFilePaser.ParseLine(line)に流し込みます。
この line の切り出しの表現に、先頭部分のカタマリの挿入、削除に対応するラッパーを被せたNativeHeadRemovableList<T>を使用します。
NativeHeadRemovableList<T>の実装(抜粋)NativeHeadRemovableList.csinternal struct NativeHeadRemovableList<T> : IDisposable where T : unmanaged { private NativeList<T> _list; private PtrHandle<int> _start; public unsafe NativeHeadRemovableList(Allocator alloc) {} public unsafe T this[int index] { get { return _list[_start + index]; } set { _list[_start + index] = value; } } public unsafe int Length { get { return _list.Length - _start; } } /* 中略 */ public unsafe void RemoveHead(int count = 1) { if (count < 1 || Length < count) throw new ArgumentOutOfRangeException("invalid length of remove target."); _start.Value = _start + count; } public unsafe void InsertHead(T* ptr, int length) { if (length <= 0) throw new ArgumentOutOfRangeException("invalid size"); // when enough space exists in head if (length <= _start) { _start.Value = _start - length; UnsafeUtility.MemCpy(this.GetUnsafePtr(), ptr, UnsafeUtility.SizeOf<T>() * length); return; } // slide internal data int new_length = length + this.Length; int len_move = this.Length; _list.ResizeUninitialized(new_length); T* dest = (T*)_list.GetUnsafePtr() + length; T* source = (T*)_list.GetUnsafePtr() + _start; UnsafeUtility.MemMove(dest, source, UnsafeUtility.SizeOf<T>() * len_move); // insert data _start.Value = 0; UnsafeUtility.MemCpy((void*)_list.GetUnsafePtr(), (void*)ptr, UnsafeUtility.SizeOf<T>() * length); } }改行を見つけたら当該部分のコピー後に
NativeHeadRemovableList<T>.RemoveHead(int)で1行分ごそっと消しますが、内部的には_startに長さ分足しているだけです。また、ブロック処理の都合上末尾に未処理のデータ片が残り、これを次回の処理開始時に配列の先頭に移動させねばなりません。そのために
NativeHeadRemovableList<T>.InsertHead(T*, int)を実装した……のですが、処理手順を考えると char配列の受け取り前に残ったデータをUnsafeUtility.MemMove()で先頭に移動して、Decoder.GetChars(byte*, int, char* ,int)に渡すポインタをその続きにすれば一番効率的だったことにこれを書いてる今気づきました。
現状で性能にほぼ影響がないので放置していますが、これから似たようなことをやる人はご注意ください。
いまどきこんな低レベルなところを弄る人がどのくらいいるかわかりませんが……具体的なブロックサイズについては、当方の検証では 2kB ~ 4kB 程度が一番よさげでしたので、2kB を規定値として採用しました。
注文も非同期な感じで受け付けてほしい
さて、これでそこそこの速度でテキストファイルをパースできるようになったわけですが、せっかく非同期なので追加の要求です。複数の GameObject から好き勝手に Load, UnLoad の要求が出される状況に対応しましょう。
複数ファイルへの複数のユーザーからの問い合わせに対応するバージョンとして
AsyncTextFileLoader<T>を作ります。
AsyncTextFileLoader<T>の実装(抜粋)AsyncTextFileLoader.cspublic class AsyncTextFileLoader<T> : IDisposable where T : class, ITextFileParser, new() { private List<string> _pathList; private Encoding _encoding; private Allocator _alloc; private Dictionary<int, ParseJob<T>> _parserPool; private int _gen; private int _blockSize; private int _maxJobCount; private NativeList<RunningJobInfo> _runningJob; private NativeList<PtrHandle<ReadStateImpl>> _state; private List<T> _data; private struct RunningJobInfo { public int FileIndex { get; } public int ParserID { get; } public RunningJobInfo(int file_index, int parser_index) { FileIndex = file_index; ParserID = parser_index; } } private enum FileAction { Store = 1, UnLoad = -1, } private struct Request { public int fileIndex { get; } public FileAction action { get; } public Request(int index, FileAction action) { fileIndex = index; this.action = action; } } private NativeQueue<int> _parserAvail; private NativeList<Request> _requestList; private NativeList<int> _updateLoadTgtTmp; private NativeList<int> _updateUnLoadTgtTmp; private UnLoadJob<T> _unLoadJob; /* 中略 */ public int MaxJobCount { get { return _maxJobCount; } set { if(value > 0) _maxJobCount = value; } } public int LoadWaitingQueue { get { return _loadWaitingQueueNum; } } public bool FlushLoadJobs { get; set; } // 管理対象のファイルが追加されたら担当のデータクラス T を生成 public unsafe void AddFile(string str) { _pathList.Add(str); _data.Add(new T()); _data[_data.Count - 1].Init(); var s_tmp = new PtrHandle<ReadStateImpl>(_alloc); s_tmp.Target->Clear(); _state.Add(s_tmp); } // データとJobの状態について index でアクセス public unsafe T this[int fileIndex] { get { if (!_state[fileIndex].Target->IsStandby) throw new InvalidOperationException($"the job running now for fileIndex = {fileIndex}."); return _data[fileIndex]; } } public unsafe ReadState GetState(int index) { return _state[index].Target->GetState(); } // Load, UnLoad ともに外部からの注文はいったんリストにためて Update() で処理 public void LoadFile(int index) { _loadWaitingQueueNum++; _requestList.Add(new Request(index, FileAction.Store)); } public void UnLoadFile(int index) { _requestList.Add(new Request(index, FileAction.UnLoad)); } public void Update() { this.UpdateImpl(this.FlushLoadJobs); this.FlushLoadJobs = false; } // リストにためた注文を一気に処理する private unsafe void UpdateImpl(bool flush_all_jobs = false) { // check job completed or not for (int i= _runningJob.Length-1; i>=0; i--) { var job_info = _runningJob[i]; var read_state = _state[job_info.FileIndex]; if (read_state.Target->JobState == ReadJobState.WaitForCallingComplete) { _parserPool[job_info.ParserID].Complete(); read_state.Target->JobState = ReadJobState.Completed; this.ReleaseParser(job_info.ParserID); _runningJob.RemoveAt(i); } } if(_unLoadJob.JobState == ReadJobState.WaitForCallingComplete) { _unLoadJob.Complete(); _unLoadJob.Clear(); } // no requests. or all available parser were running. retry in next Update(). if (_requestList.Length == 0 || (_maxJobCount - _runningJob.Length <= 0 && !flush_all_jobs)) { return; } //--- extract action _updateLoadTgtTmp.Clear(); _updateUnLoadTgtTmp.Clear(); for (int i=0; i<_requestList.Length; i++) { var act = _requestList[i]; if (act.action == FileAction.Store) { var tgt_state = _state[act.fileIndex]; if (tgt_state.Target->RefCount == 0) { _updateLoadTgtTmp.Add(act.fileIndex); } tgt_state.Target->RefCount++; } else { _updateUnLoadTgtTmp.Add(act.fileIndex); } } _requestList.Clear(); //--- preprocess unload action for (int i=0; i< _updateUnLoadTgtTmp.Length; i++) { int id = _updateUnLoadTgtTmp[i]; var tgt_state = _state[id]; tgt_state.Target->RefCount--; if (tgt_state.Target->RefCount == 0) { int found_index = _updateLoadTgtTmp.IndexOf(id); if (found_index >= 0) { // remove from loading order (file loading is not performed) _updateLoadTgtTmp.RemoveAtSwapBack(found_index); } else { // remove from loaded data if (_unLoadJob.JobState == ReadJobState.Completed && tgt_state.Target->IsStandby) { //--- unload in job (workaround for LargeAllocation.Free() cost in T.UnLoad().) _unLoadJob.AddUnLoadTarget(id, _data[id], _state[id]); } else { // now loading. unload request will try in next update. tgt_state.Target->RefCount++; // reset ref count this.UnLoadFile(id); } } } if (tgt_state.Target->RefCount < 0) { throw new InvalidOperationException($"invalid UnLoading for index = {id}."); } } _updateUnLoadTgtTmp.Clear(); // schedule jobs //--- unload job _unLoadJob.UnLoadAsync(); //--- supply parsers for load job int n_add_parser = Math.Max(_updateLoadTgtTmp.Length - _parserAvail.Count, 0); if (!flush_all_jobs) { n_add_parser = Math.Min(this.MaxJobCount - _parserPool.Count, n_add_parser); } for (int i = 0; i < n_add_parser; i++) this.GenerateParser(); //--- run jobs int n_job = Math.Min(_parserAvail.Count, _updateLoadTgtTmp.Length); for(int i=0; i<n_job; i++) { int file_index = _updateLoadTgtTmp[i]; int p_id = _parserAvail.Dequeue(); var p_tmp = _parserPool[p_id]; var p_state = _state[file_index]; p_tmp.BlockSize = _blockSize; p_tmp.ReadFileAsync(_pathList[file_index], _encoding, _data[file_index], p_state); _runningJob.Add(new RunningJobInfo(file_index, p_id)); } //--- write back excessive queue _loadWaitingQueueNum = 0; for (int i=n_job; i<_updateLoadTgtTmp.Length; i++) { int id = _updateLoadTgtTmp[i]; _state[id].Target->RefCount--; // reset ref count this.LoadFile(id); _loadWaitingQueueNum++; } _updateLoadTgtTmp.Clear(); } }大まかな流れとしては、
- (
Update()が呼ばれるまで) 任意の Load, UnLoad を受け付けてすべてリストにためる- たまった注文を Load と UnLoad に分ける
- まず Load だけを取り出し、各ファイルの参照カウントをインクリメントし、
ここで参照カウントが0→1になったなら LoadJob の予約表に書きこむ- 次に UnLoad だけを取り出し、各ファイルの参照カウントをデクリメントし、
ここで参照カウントが1→0になったなら、
- LoadJob の予約表に該当ファイルの予約があれば、それを消す (その結果何もしない)
- 予約がなければ UnLoadJob の対象リストに入れる
- UnLoadJob を
schedule()する。- LoadJob を
schedule()する。Load だけでなく UnLoad も Job にしてしまっていますが、これについては次節で説明します。
ここにさらに同時に動作する LoadJob の最大数
MaxJobCountに合わせて、同時に保持するパーサーの数とジョブの割り当てを管理しています。
パーサーは一度動かすとファイル丸ごとをバッファすること、またそもそもファイルのロードは多数を同時に走らせることは稀という前提で、メモリ消費の削減の観点からこのような設計にしました。しかし、後述の課題によりMaxJobCountはせいぜい 1 ~ 2 ぐらいまでしかまともに動かないことが判明しました。実際の運用では、 LoadJob 待機中の注文数を取得するプロパティ
AsyncTextFileLoader<T>.LoadWaitingQueueを参照して注文する GameObject 側がタイミングを調節する形になるでしょう。大きなデータのUnLoad
大きなファイルの読み込みもそうですが、メモリ領域の破棄にもそれなりにコストがかかります。
大容量のデータをいくつも一気に UnLoad したりするとLargeAllocation.Free()に ms 単位で持っていかれかねません。せっかくパース処理そのものはワーカースレッドに追い出したのに、これでメインスレッドが遅くなったら片手落ちです。
幸いNativeContainerのアロケータはワーカースレッドでも動くので、多数のファイルを管理するAsyncTextFileLoader<T>ではUnLoad()もワーカースレッドにやらせてメインスレッドを身軽にします。
ここで、JobHandle.Schedule()の呼び出しコストを削減するため、UnLoad()対象のデータ (のGCHandle)のリストを1つの Job に渡して一気に UnLoad させます。
UnLoad 用の Job は以下のようになります。
UnLoadJob<T>の実装(抜粋)ParseJob.csinternal struct UnLoadJobTarget<Tdata> where Tdata : class, ITextFileParser { internal GCHandle<Tdata> data; internal PtrHandle<ReadStateImpl> state_ptr; // UnLoad 対象の State internal int file_index; public UnLoadJobTarget(int file_index, Tdata data, PtrHandle<ReadStateImpl> state_ptr) { this.data = new GCHandle<Tdata>(); this.data.Create(data); this.state_ptr = state_ptr; this.file_index = file_index; } public unsafe void UnLoad() { this.state_ptr.Target->JobState = ReadJobState.UnLoaded; this.data.Target.UnLoad(); } } internal struct UnLoadJobInfo { internal ReadJobState job_state; internal JobHandle job_handle; internal Boolean alloc_handle; } internal struct UnLoadJob<Tdata> : IJob, IDisposable where Tdata : class, ITextFileParser { internal NativeList<UnLoadJobTarget<Tdata>> _target; internal PtrHandle<UnLoadJobInfo> _info; // UnLoadJob の管理情報 public unsafe UnLoadJob(Allocator alloc) { _target = new NativeList<UnLoadJobTarget<Tdata>>(alloc); _info = new PtrHandle<UnLoadJobInfo>(alloc); _info.Target->job_state = ReadJobState.Completed; } public unsafe void Dispose() { this.DisposeHandle(); _target.Dispose(); _info.Dispose(); } private unsafe void DisposeHandle() { if (_info.Target->alloc_handle) { for (int i = 0; i < _target.Length; i++) _target[i].data.Dispose(); _info.Target->alloc_handle = false; } } public void Clear() { this.DisposeHandle(); _target.Clear(); } public unsafe void AddUnLoadTarget(int file_index, Tdata data, PtrHandle<ReadStateImpl> state_ptr) { _target.Add( new UnLoadJobTarget<Tdata>(file_index, data, state_ptr) ); _info.Target->alloc_handle = true; } public unsafe JobHandle UnLoadAsync() { if(_target.Length > 0) { _info.Target->job_state = ReadJobState.UnLoadJob; _info.Target->job_handle = this.Schedule(); return _info.Target->job_handle; } else { // no action return new JobHandle(); } } public unsafe ReadJobState JobState { get { return _info.Target->job_state; } } public unsafe void Execute() { for (int i = 0; i < _target.Length; i++) _target[i].UnLoad(); _info.Target->job_state = ReadJobState.WaitForCallingComplete; } public unsafe void Complete() { _info.Target->job_handle.Complete(); _info.Target->job_state = ReadJobState.Completed; } }このジョブを1つインスタンス化しておいて、 UnLoad の注文が来たら
UnLoadJob<T>.AddUnLoadTarget(int, T, PtrHandle<ReadStateImpl>)で対象の data を渡し、UnLoadJob<T>.UnLoadAsync()で後始末させます。課題
Burst でもっと早くならない?
いつになるかは不明ですが、公式の案内では
charには対応する予定らしいので、その暁にはもっと早くなるはず。Burst does not support the following types:
- char (this will be supported in a future release)
- string as this is a managed typeライブラリ内部ではASCII範囲の値しか検索、比較していないので、
charをすべてunit16あたりにキャストして、関数ポインタ経由でBurstさせればもっと早くなる可能性は大いにあります。
しかし、公式が対応すると明言していますし、上記の手法で Burst による高速化が特に期待されるホットスポットはTryParse()関数なので、Burst がcharに対応したならユーザーデータクラスのParseLine(line)をまるごと適用したほうがはるかに効果的でしょう。複数のファイルを同時に読ませると途端に遅くなる
デモシーン
/Assets/NativeStringCollections/Demo/Scenes/Demo_AsyncMultiFileManagement.unityで、
AsyncTextFileLoader<T>を使用して n個 のファイルを同時に読み込む指示を出すと、同時に始まった個々のジョブの処理時間が n倍 になり、結局速くなくなるどころか遅延時間の分だけ1個ずつ読ませていたほうがまし、という症状が出ています。
ストレージ <-> メモリ間、あるいは CPU <-> メモリ間の転送速度に引っかかったかとも思いましたが、プロファイラで確認したところメモリ転送負荷になりそうなAsyncReadManager.Read()やNativeList<T>.Add()の処理時間をはじめ、ジョブの処理時間全体がプロファイラ上では 1ジョブの状態とほぼ同じ時間でした。しかしAsyncTextFileReader<T>内部のSystem.Diagnotics.Stopwatchによる処理時間の計測結果、および実時間では一気に動作が遅くなります。
小さなファイルで試すとプロファイラの経過時間と実時間が一致しました。1秒を超えるような長時間のジョブはプロファイラ内の経過時間がバグります。flushingの有無による変化を下記に示します。(Deep Profile)
LoadJob を flush している場合の、ParseJob 内の各関数の経過時間は下記の通りです。
(数値は 6 Job の総和)
- 0.211 ms File.Read() (間に隙間はあるものの合計 1.0 ms 以下)
- 4751.49 ms ParseText()
- 704.39 ms ParseLinesFromBuffer()
- 3684.18 ms CharaDataParser.ParseLine()
- 165.25 ms ReadOnlyStringEntity.op_Equality()
- 348.70 ms ReadOnlyStringEntity.Slice()
- 904.67 ms StringSplitterExt.Split()
- 1071.26 ms StringParserExt.TryParse()
- 329.31 ms NativeBase64Decoder.GetBytes()
- 552.84 ms NativeStringList.Add()
- 155.91 ms NativeStringList.get_Last
- (156.24 ms others)
- 35.30 ms PostReadProc()
一方で job を1つずつ実行した場合の経過時間の一例は下記のとおりです。
- 0.035 ms File.Read()
- 150.33 ms ParseText()
- 22.24 ms ParseLinesFromBuffer()
- 116.83 ms CharaDataParser.ParseLine()
- 5.11 ms ReadOnlyStringEntity.op_Equality()
- 10.82 ms ReadOnlyStringEntity.Slice()
- 29.93 ms StringSplitterExt.Split()
- 34.11 ms StringParserExt.TryParse()
- 8.65 ms NativeBase64Decoder.GetBytes()
- 17.01 ms NativeStringList.Add()
- 4.66 ms NativeStringList.get_Last
- (6.54 ms others)
- 0.013 ms PostReadProc()
そして各関数の
ParseText()内の相対実行時間を比較すると下表のようになります。
関数名 t[ms]_1Job ratio_1Job t[ms]_6Job ratio_6Job slower/Job File.Read() 0.035 - 0.211 - 1.00x ParseText() 150.33 100.0% 4751.49 100.0% 5.27x ParseLinesFromBuffer() 22.24 14.8% 704.39 14.8% 5.29x CharaDataParser.ParseLine() 116.83 78% 3684.18 77.5% 5.26x ReadOnlyStringEntity.op_Equality() 5.11 3.40% 165.25 3.48% 5.39x ReadOnlyStringEntity.Slice() 10.82 7.20% 348.70 7.34% 5.37x StringSplitterExt.Split() 29.93 19.9% 904.67 19.0% 5.04x StringParserExt.TryParse() 34.11 22.7% 1071.26 22.5% 5.23x NativeBase64Decoder.GetBytes() 8.65 5.75% 329.31 6.90% 6.35x NativeStringList.Add() 17.01 11.3% 552.84 11.6% 5.42x NativeStringList.get_Last 4.66 3.10% 155.91 3.28% 5.58x (others) 6.54 4.35% 156.24 3.29% 3.98x PostReadProc() 0.013 - 35.30 - 452x 表右端の
slower/Jobは6Jobの実行時間を1Jobの実行時間で割った後、さらに6で除して Job 1つあたり何倍遅くなったかの比です。
不思議なことに関数全体にわたって均等に遅くなっています。プロファイラによる観測データの転送でメモリ帯域を食われた可能性も考えましたが、
リリースビルドで同じ 4096 サイズに対し、LoadJob 1つの処理時間は約 6.5 ms (@ 1 Job)-> 約 80 ms (@ 6 Job)
とむしろ比率的にはよりひどい状態で同様の現象が見られます。
また、
PostReadProc()はファイル先頭部分に Base64 で埋め込んだ全データのIDのリストをParseLine()で実際に解析したCharaDataのIDと照合して読み込みエラーがないか確認をするのと、NativeStringList.Add()の繰り返しでバッファが伸長し、各CharaDataの name の参照先が無効になっているはずなのでその再構築をしています。
つまりほぼ計算なしに全データを走査して long の比較とchar配列のコピーをしているだけなので、真にメモリバウンドな処理をするとここまで遅くなるということを示していると考えられます。以上から、やはりキャッシュミスとは異なる現象が起きているように思われ、
自明並列の部分が並列化でなぜ遅くなるのか私の頭では原因がつかめません……
AsyncReadManager自体あまり公式 script reference 以外の情報がなく、そもそもアセットバンドル等のひとまとめにしたバイナリデータをロードするのに用意されたAPIで、完全に用途が違うものを変な使い方している、と言われればそれまでですが……
Unityの中の人に聞ければ解決するかもしれませんが、ちょっとそこまでのお金はないのでだれか解決してくれるとすごくたすかります(他力本願)参考記事
【Unity】ファイルを非同期で読み込んでアンマネージドメモリに展開できるAsyncReadManagerを試してみた
【Unity】NativeArrayについての解説及び実装コードを読んでみる
【Unity】UnsafeUtilityについて纏めてみる
【Unity】BurstCompilerをJobSystem以外でも使いたい
Unity C# Job Systemに参照型を持ち込む
【Unity】スタックトレースを有効にしてNativeArrayのメモリリークを探す
【Unity】UnsafeUtility基礎論【入門者向け】System.Text.Encoding で Shift JIS を使いたい
【C#】Big Size Structが値コピーでつらいならin引数で値コピーしなければいいじゃない!! < それ本当?
- 投稿日:2021-02-21T09:19:36+09:00
[Unity]複数タブで作業効率アップ
- 投稿日:2021-02-21T07:26:21+09:00
[Unity3D] Cinematicsでのアニメーション映像作成
フォローカメラが設定されている状態から始める
1, ヒエラルキーに空のCinematicsを作成し、ProjectにもCnematicsフォルダを作成。
FollowCameraのPriorityを100にする。
2, CinemachineのCreateVirtualCameraを選択する
3, CM Reveal1に名前を変更し、Soloを押してカメラ位置を合わせていく
5, WindowのSequencing→timelinesを選択する
6, intoSequenceを選択した状態でCreateを押す
8, CM Reveal1をスペースにドラッグし、Cameraを選択する
9, 別のCinemaとくっつける場合は同位置にドラッグしブレンドする
・他にくっつけたいものがあれば同様に作成する
DollyCameraでの実装
1, WindowのCreateDollyCameraWithTrackを選択する
2, DollyTrackをプラスボタンで増やしながら作成する
3, CMDolly1のSoloを押しカメラを合わせ、Distanceにする
4, CMDolly1にオブジェクトを設定し、PathPosition、Normalizadに設定する
5, CMDolly1をTimeLineにドラッグする.空の部分にもドラッグし、アニメータを作成しておく。
6, CMDolly1のPathPositionをゼロ0にする
7, CMDolly1の終わり間際の部分でRecodingを押す
8, Recodingが始まったらCMDolly1のPathPositionを1にする
9, Recoding状態のままCMDolly1の始まり部分を選択する
11, Recodingボタンを押し、停止する。
12, CMReveal2に合わせてから※マーク→Secondsを押す
13, CMDolly1の開始部分に合わせる(開始時間を覚えておく)
14, この間をダブルクリックするとAnimationが開かれる
15, CmDolly1の開始時間に合わせてイベントが発生するように位置を合わせる
Triggerでアニメーションを実行する
1, introSequenceにBoxcolliderをつける
3, introSequenceにrigidBodyをつけ、Grabityは外し、Kinematicはつける
4, PlayerにCapsuleColliderをつけ、サイズを調整する
5, IntroSequenceのPlayOnAwakeを外す
6, IntroSequenceのLayer→IgnoreRayCastに設定する
7, CinematicTriggerスクリプトを作成し、下記のコードを記述する
(Tagも使って呼び出しているのでplayerにはPlayerTagを設定しておくこと)CinematicTriggerusing System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; namespace RPG.Cinematics { public class CinematicTrigger : MonoBehaviour { bool alreadyTriggered = false; private void OnTriggerEnter(Collider other) { if (!alreadyTriggered && other.gameObject.tag == "Player") { alreadyTriggered = true; GetComponent<PlayableDirector>().Play(); } } } }8, IntroSequenceにCinematicTriggerスクリプトをアタッチする
イベントが発生したらplayerを立ち止まらせる
1, CinematicControlRemoverスクリプトを作成し、IntroSequenceにアタッチする
2, CinematicControlRemoverとFighterに下記コードを記述する
CinematicControlRemoverusing System.Collections; using System.Collections.Generic; using UnityEngine; using UnityEngine.Playables; namespace RPG.Cinematics { public class CinematicControlRemover : MonoBehaviour { private void Start() { GetComponent<PlayableDirector>().played += DisableControl; GetComponent<PlayableDirector>().stopped += EnableControl; } void DisableControl(PlayableDirector pd) { Debug.Log("DisableControl"); } void EnableControl(PlayableDirector pd) { Debug.Log("EnableControl"); } } }Fighterusing UnityEngine; using RPG.Movement; using RPG.Core; namespace RPG.Combat { public class Fighter : MonoBehaviour, IAction { [SerializeField] float weaponRange = 2f; [SerializeField] float timeBetweenAttacks = 1f; [SerializeField] float weaponDamage = 5f; Health target; float timeSinceLastAttack = Mathf.Infinity; private void Update() { timeSinceLastAttack += Time.deltaTime; if (target == null) return; if (target.IsDead()) return; if (!GetIsInRange()) { GetComponent<Mover>().MoveTo(target.transform.position, 1f); //1fを追加 } else { GetComponent<Mover>().Cancel(); AttackBehaviour(); } } private void AttackBehaviour() { transform.LookAt(target.transform); if (timeSinceLastAttack > timeBetweenAttacks) { TriggerAttack(); timeSinceLastAttack = 0; } } private void TriggerAttack() { GetComponent<Animator>().ResetTrigger("stopAttack"); GetComponent<Animator>().SetTrigger("attack"); } void Hit() { if(target == null) { return; } target.TakeDamage(weaponDamage); } private bool GetIsInRange() { return Vector3.Distance(transform.position, target.transform.position) < weaponRange; } public bool CanAttack(GameObject combatTarget) { if (combatTarget == null) { return false; } Health targetToTest = combatTarget.GetComponent<Health>(); return targetToTest != null && !targetToTest.IsDead(); } public void Attack(GameObject combatTarget) { GetComponent<ActionScheduler>().StartAction(this); target = combatTarget.GetComponent<Health>(); } public void Cancel() { StopAttack(); target = null; GetComponent<Mover>().Cancel(); //追加 } private void StopAttack() { GetComponent<Animator>().ResetTrigger("attack"); GetComponent<Animator>().SetTrigger("stopAttack"); } } }
- 投稿日:2021-02-21T01:34:32+09:00
UnityでPytorchライクの機械学習ライブラリを作る。 4日目:行列積の勾配処理
今回すること
今回は行列積の勾配処理を実装していきますが、その前に行列の場合はどのようにして勾配を計算するか考えていきます。
まず$N\times L$の行列$A$と$L\times M$の行列$B$の積を$C=AB$とおきます。
この時$C$の$i$行$j$列目の要素$C_{ij}$は以下のように計算されます。C_{ij} = \sum_{k=1}^{L}A_{ik}B_{kj}適当な$s$を用いて$A_{is}$で両辺を微分すると
\frac{\partial C_{ij}}{\partial A_{is}}=B_{sj}よって最終的な関数の出力を$E$とすると、
\frac{\partial E}{\partial A_{is}}=\sum_{j=1}^{M}\frac{\partial E}{\partial C_{ij}}B_{sj}これは実装上では
A.Grad[i, s]=\sum_{j=1}^{M}C.Grad[i, j]\cdot B[s, j]ここで、転置$B[s, j]=B^T[j, s]$を代入すると
A.Grad[i, s]=\sum_{j=1}^{M}C.Grad[i, j]\cdot B^T[j, s]よって$A.Grad=C.Grad\cdot B^T$
同様に$B$について考えると
B.Grad[s, j]=\sum_{i=1}^{N}C.Grad[i, j]\cdot A^T[s, i]となって$B.Grad=A^T\cdot C.Grad$
これらを計算すれば良い。実装
上のAとBで計算の仕方が異なるので別のループで計算する。
Dot.csnamespace Rein.Functions{ public class Dot: BinaryFunction{ public Dot():base("Dot"){ } // ... protected override void BinaryBackward() { int N = this.Left.Size / this.Left.Shape.Last(); int M= this.Right.Shape[1]; int L = this.Right.Shape[0]; // A(左側) for (int i=0; i < N; i++){ for(int j=0; j < L; j++){ R sum_left = 0; for(int k=0; k < M; k++){ sum_left += this.Out.Grad[i * M + k] * this.Right.Data[j * M + k]; } this.Left.Grad[i * L + j] += sum_left; } } // B(右側) for (int k=0; k < N; k++){ for (int i=0; i < L; i++){ for (int j=0; j < M; j++){ this.Right.Grad[i * M + j] += this.Out.Grad[k * M + j] * this.Left.Data[k * L + i]; } } } } } }A(左側)の処理ですが運のいいことにそのまま計算すれば配列内の移動幅が少なく済むのでループ交換は必要ありません。Bの処理はkを最も上のループにすれば移動幅が少なく済みます。
Aで一度sumに代入している理由ですが、一番下のkのループで毎回配列にアクセスすると速度が落ちるので一つのに入れておき最後に代入することで速度が上がります。あとこれにループアンローリングと代入を減らした物が最終的な実装となります。
Dot.cs// ... protected override void BinaryBackward() { int N = this.Left.Size / this.Left.Shape.Last(); int M= this.Right.Shape[1]; int L = this.Right.Shape[0]; int i, j, k, k1, k2, k3; for (i=0; i < N; i++){ for(j=0; j < L - 3; j++){ R sum = 0; for(k=0; k < M; k+=4){ sum += this.Out.Grad[i * M + k] * this.Right.Data[j * M + k] + this.Out.Grad[i * M + k + 1] * this.Right.Data[j * M + k + 1] + this.Out.Grad[i * M + k + 2] * this.Right.Data[j * M + k + 2] + this.Out.Grad[i * M + k + 3] * this.Right.Data[j * M + k + 3]; } for(; k < M; k++){ sum += this.Out.Grad[i * M + k] * this.Right.Data[j * M + k]; } this.Left.Grad[i * L + j] += sum; } } for (k=0; k < N - 3; k+=4){ k1 = k + 1; k2 = k + 2; k3 = k + 3; for (i=0; i < L; i++){ for (j=0; j < M; j++){ this.Right.Grad[i * M + j] += this.Out.Grad[k * M + j] * this.Left.Data[k * L + i] + this.Out.Grad[k1 * M + j] * this.Left.Data[k1 * L + i] + this.Out.Grad[k2 * M + j] * this.Left.Data[k2 * L + i] + this.Out.Grad[k3 * M + j] * this.Left.Data[k3 * L + i]; } } } for (; k < N; k++){ for (i=0; i < L; i++){ for (j=0; j < M; j++){ this.Right.Grad[i * M + j] += this.Out.Grad[k * M + j] * this.Left.Data[k * L + i]; } } } }終わりに
ということで今回は行列積の勾配処理を実装しました。思いの外時間がかかってしまいましたが、重要な部分なのでまあいいでしょう。次こそは単項演算の実装をします。というか実装自体は終わっていてそれを文章にまとめるだけなのですぐに終わると思います。
















































