20201223のUnityに関する記事は14件です。

Unity 2D #4 ScrollViewにおけるlayout

参考URL:https://tech.griphone.co.jp/2018/12/18/advent-calendar-20181218/

LayoutGroup

Child Controls Size : 子のLayout要素によって算出された幅によって、子自身の幅を変える設定。
Child Force Expand : 親の幅に対して子に余白がある場合、子を引き伸ばす設定。

Layout Element

Min ~~ : 最低でもこのサイズは確保してほしい、という値。これ以上小さくならない。
Preffered ~~ : 余っているサイズがあればこのサイズまで描画する。
Flexible ~~ : 余っているサイズの中で、全てのFlexible間で占める割合

●グリッド状に並べるオブジェクトの数をスクリプトで変更する方法
ScrollViewのContentにGridLayoutGroupを、オブジェクトにLayout Elementをアタッチする。

// あらかじめアタッチしておく
public GameObject ScrollContent;


// オブジェクトを並べる空間の横幅
double content_width = Screen.width - 20;
// いくつのオブジェクトを並べるか(ここでは、横幅50のオブジェクトを10感覚で並べようとしている)
int constraint_count = (int)(content_width/60);

// スクロールビューの横に並べるオブジェクト数の変更
ScrollContent.GetComponent<GridLayoutGroup>().constraintCount = constraint_count;
// オブジェクト同士の隙間を計算
double content_space = (content_width - constraint_count*50)/(constraint_count-1);
// オブジェクト同士の隙間をスクロールに反映
ScrollContent.GetComponent<GridLayoutGroup>().spacing = new Vector2((float)content_space, 50);
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

オブジェクトを追いかけるカメラをスクリプトで実現する方法

概要

Unity3Dにて、あるオブジェクトを追いかけるカメラをスクリプトで表現する方法を記します。

なお、本記事ではこのような追従するカメラを便宜的に「相対カメラ」と呼ぶことにします(オブジェクトとの相対的な位置を保っているから)。

Unityバージョン:Unity 2019.4.14f1
使用言語:C#

結論

本記事で扱う相対カメラの特徴:

  • オブジェクトの移動に連動する(一定の距離を保ちながら移動する)
  • オブジェクトとの相対的な距離は、再生時のお互いの位置で決める
  • オブジェクトの回転には連動しない

サンプルコードは以下の通りです。

RelativeCamera.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RelativeCamera : MonoBehaviour
{
    public GameObject target; // カメラと連動するオブジェクト(インスペクターで取得)
    private Vector3 offset; // カメラとオブジェクトの相対的な距離

    void Start()
    {
        // offsetに再生時の相対的な距離を代入
        offset = transform.position - target.transform.position;
    }

    // Upddate()だと他のスクリプトの処理が終わる前に動く可能性がある
    void LateUpdate()
    {
        // 相対カメラの位置ベクトル = オブジェクトの位置ベクトル + offset位置ベクトル
        transform.position = target.transform.position + offset;
    }
}

本論

再生時のカメラとオブジェクトの距離を保つようなカメラの動きを表現しました。

そのため、Start()内で再生時のカメラとオブジェクトの距離をoffsetに代入し、
それを使ってLateUpdate()内で相対カメラの位置を更新し続けるようにしました。


中身は、単純なベクトルの足し算引き算を使っています。

Start()内では、

Sample1
// offset = 相対カメラの位置ベクトル - オブジェクトの位置ベクトル
offset = transform.position - target.transform.position;

としてoffsetを計算しています。

同様にして、LateUpdate()内では、

Sample2
// 相対カメラの位置ベクトル = オブジェクトの位置ベクトル + offset
transform.position = target.position + offset;

として相対カメラの位置ベクトルtransform.positionを計算しています。


また、カメラ移動のスクリプトをLateUpdate()内に書きました。

これは画面のカクつきを防ぐためです。

両者ともUpdate()内に書いてしまうと、カメラ移動がオブジェクト移動に先走ってしまい、
画面が変にカクついてしまう恐れがあります。

そのため、オブジェクト移動のスクリプトはUpdate()内に書くことを
想定して、
カメラ移動のスクリプトはLateUpdate()内に書きました。

参考文献

プレイヤーに追従するカメラ(カクつかない方法、滑らかに追従する方法) - ゆーじのUnity開発日記
【Unity C#】カメラの自動追従 | フタバゼミ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ゲームオブジェクトを追いかけるカメラをスクリプトで実現する方法

概要

Unity3Dにて、あるゲームオブジェクトを追いかけるカメラをスクリプトで表現する方法を記します。

なお、本記事ではこのような追従するカメラを便宜的に「相対カメラ」と呼ぶことにします(オブジェクトとの相対的な位置を保っているから)。

Unityバージョン:Unity 2019.4.14f1
使用言語:C#

結論

本記事で扱う相対カメラの特徴:

  • ゲームオブジェクトの移動に連動する(一定の距離を保ちながら移動する)
  • ゲームオブジェクトとの相対的な距離は、再生時のお互いの位置で決める
  • ゲームオブジェクトの回転には連動しない

サンプルコードは以下の通りです。

RelativeCamera.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class RelativeCamera : MonoBehaviour
{
    public GameObject target; // カメラと連動するゲームオブジェクト(インスペクターで設定)
    private Vector3 offset; // カメラとゲームオブジェクトの相対的な距離

    void Start()
    {
        // offsetに再生時の相対的な距離を代入
        offset = transform.position - target.transform.position;
    }

    // Upddate()だと他のスクリプトの処理が終わる前に動く可能性がある
    void LateUpdate()
    {
        // 相対カメラの位置ベクトル = ゲームオブジェクトの位置ベクトル + offset位置ベクトル
        transform.position = target.transform.position + offset;
    }
}

本論

再生時のカメラとゲームオブジェクトの距離を保つようなカメラの動きを表現しました。

そのため、Start()内で再生時のカメラとゲームオブジェクトの距離をoffsetに代入し、
それを使ってLateUpdate()内で相対カメラの位置を更新し続けるようにしました。


中身は、単純なベクトルの足し算引き算を使っています。

Start()内では、

Sample1
// offset = 相対カメラの位置ベクトル - ゲームオブジェクトの位置ベクトル
offset = transform.position - target.transform.position;

としてoffsetを計算しています。

同様にして、LateUpdate()内では、

Sample2
// 相対カメラの位置ベクトル = ゲームオブジェクトの位置ベクトル + offset
transform.position = target.position + offset;

として相対カメラの位置ベクトルtransform.positionを計算しています。


また、カメラ移動のスクリプトをLateUpdate()内に書きました。

これは画面のカクつきを防ぐためです。

両者ともUpdate()内に書いてしまうと、カメラ移動がゲームオブジェクト移動に先走ってしまい、
画面が変にカクついてしまう恐れがあります。

そのため、ゲームオブジェクト移動のスクリプトはUpdate()内に書くことを
想定して、
カメラ移動のスクリプトはLateUpdate()内に書きました。

補足(カメラをゲームオブジェクトの子オブジェクトにする)

カメラをゲームオブジェクトの子オブジェクトにする方法もありますが、これは以下のようなメリットとデメリットがあります。

メリット:楽ちん
デメリット:ゲームオブジェクトの回転にも連動してしまう

例えば、ゲームオブジェクトが回転しながら移動するボールの場合、カメラもグルグルと回転してしまうわけです。
もしこれでも問題がないのなら、この方法を採用してもいいかもしれません。

参考文献

プレイヤーに追従するカメラ(カクつかない方法、滑らかに追従する方法) - ゆーじのUnity開発日記
【Unity C#】カメラの自動追従 | フタバゼミ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unityで「まとも」なテキスト描画を行いたい

title2.png

 この記事は KLab Engineer Advent Calendar 2020 24日目の記事です。
Qiitaへの投稿は去年の12/24、つまりきっちり1年前のアドカレに続いて2回目になります suzuna-honda といいます。よろしくお願いします。

はじめに

 Unityでテキストの描画を行う際、標準で用意されている選択肢には大きく2つ、「uGUI Text」と「TextMesh Pro」があります。

uGUI Text

 uGUI Textは古くから存在する、Unity標準のテキスト描画システムです。
フォントファイルからビットマップを動的に生成し描画するダイナミックフォントという仕組みのおかげで、とにかく何も考えずにポン置きでテキストを画面に配置できる非常に便利なコンポーネントですが、設計の古さ故か、いくつか頭を抱えてしまう問題点を抱えています。

 まず、ダイナミックフォントとはいえ最終的にはビットマップでフォントを描画しますので、テキストを持つオブジェクトを拡大すると、見た目がぼやけるだけではなく高周波の階段状ノイズが発生します。フォントサイズ自体を大きくすれば改善されますが、この場合はビットマップの再構築が必要となります。
clip_2.png
 より深刻なのが、uGUI Textのアウトライン描画です。アウトラインはテキストの視認性確保のためにとても重要な機能です。これがどのように実装されているかというと、テキスト描画用の頂点バッファを丸々4倍分複製し、斜め4方向に位置を少しずらして頂点カラーを書き換えています。このため、アウトラインを太くすると下の画像のようにアウトラインがおかしくなります。
clip_5a.png
 また、アウトラインの付いているテキストが更新される度に、CPU上で頂点バッファの複製/書き換えが行われます。一度に更新される文字数が多くなると、この頂点バッファ複製処理でメインスレッドを長時間占有してしまいますので、見苦しいスパイクの原因になり得ます。

 何故かインデックスバッファ=共有頂点を使っておらず1文字の矩形一つに6頂点が使われており、この点での効率も良くありません。パフォーマンスチューニングの観点では割とあり得ない作りです。(Unityは例えば他にも、公式ライブラリのフルスクリーンポスト処理でトライアングル2枚を使っていたり(一般的には1枚で画面全体を覆います、誤差とはいえその方が効率良いですからね)とか、なんで?って仕様がとても多いです)
clip_7a.png
 また、シンプルに重ね塗りを繰り返しますので、結果オーバードローの面積も5倍となり、GPUフィル負荷も無視できなくなります。
clip_9a.png
 複製する頂点を4方向より更に細かく増やすことで、見た目の破綻を軽減するハックがよく使われているようです。が、標準で4方向までとなっているのは上記のような負荷の問題があってのものですから、あまり安易に濫用するのはお勧めできません(複製する頂点数も、オーバードロー回数もリニアに増えてしまいます)。更にアウトラインを2つ3つ重ねたりした日には、そこには宇宙が広がっています(困ったことに、そこそこよく見かけるんですよね...)。

 その他にも例えば「(標準では)字間が調整できない」など諸々の問題があり、Unity社としては将来的にはuGUI Textを非推奨として、次に説明するTextMesh Proを次世代の標準テキスト描画システムとして移行を推奨しているようです。

TextMesh Pro

 TextMesh Proはもともと有償の外部アセットであったものがUnity社に買収され無償のパッケージとして誰でも使えるようになったテキスト描画システムです。
大きな特徴としてはやはり、Signed Distance Field(SDF)テクスチャを用いたフォント描画手法を用いている点がまず挙げられます。

Signed Distance Field(SDF)フォント?

https://steamcdn-a.akamaihd.net/apps/valve/2007/SIGGRAPH2007_AlphaTestedMagnification.pdf
 こちらの資料が大元ネタとなっている描画手法です。
SDFを用いたフォントの描画では、テクスチャにはカラー情報を持たせず、距離をもたせます。
efe.png
 直訳「符号付き距離場」の名前の通り、境界線(フォントでいえば縁の部分ですね)からの距離を各テクセルに格納します。更にフォントの内側と外側で+-の符号を変えることで、その場所がフォントとして塗る場所なのか、塗らない場所なのかを判断できるようになります。
clip_4.png
(https://dspace.cvut.cz/bitstream/handle/10467/62770/F8-DP-2015-Chlumsky-Viktor-thesis.pdf より引用)

 この「距離」というのがミソで、これによりテクスチャを拡大した際、バイリニア補間による恩恵を最大限得られるようになります。要は境界線がボケることなく、くっきりとした輪郭で描画することができるのです。この「拡大表示に耐性がある」ことがSDFを使ってフォントを描画する最大のメリットです。
geg.png
↑をSDFフォントとしてレンダリングした結果が↓になります。どんなに拡大しても、uGUI Textのようなボヤけた表示にはなりません。
alp.gif
 ただし色の情報をテクスチャに持たせられませんので、テクスチャから拾える距離の情報と、別途用意した外部パラメータを用いて、シェーダを使ってコネコネしてフォントの描画色を生成する必要があります。ですがこれはむしろプラス要素で、見方を変えると、アウトラインやグローといったフォントの装飾を低コストで用意することが出来るようになるわけです。これもSDFフォントを採用する大きなメリットの一つとなっています。

 TextMesh Proは特にこの装飾の追加に異常なほど力を入れており、無数のバリエーション・カスタマイズを実現しています。とはいえ、個人的にはゴテゴテした装飾は好きではなく、特にゲーム用途においては高品質なアウトライン・グロー・ドロップシャドウがあれば十分かな、と認識しています。「高品質な」というのが大事なポイントです。

multi-channel SDF?

 SDFフォントは特に拡大時の輪郭線において優秀なルックを提供してくれますが、一方で欠点もあります。解像度が十分でないとき、「角が欠ける/丸くなる」というのがその典型です。

clip_11.png
https://github.com/Chlumsky/msdfgen より引用)

 この画像の中央や右の文字のように、1つの距離情報(=single-channel)を持たせた一般的なSDFテクスチャを使って描画を行うと、角が欠けたり丸まってしまうのです。この問題を解決するために、複数の距離情報をもたせた(=multi-channel)SDFフォントテクスチャ、というものがあります。左の文字がmulti-channelなSDFで描画した文字に該当しますが、角が正しく表現されていますね。
gress.png
 具体例:角の丸まった矩形2つのANDを取れば、角の丸まっていないシャープな菱形が手に入る

 一見すると欠点を克服したベストな選択のように見えますが、実は致命的なネガティブ要素があります。複数チャンネルを組み合わせて一つの境界を作るアルゴリズムの都合上、「境界線からのスカラー距離」という情報が手に入らなくなってしまうのです。これが何を意味しているかというと、フォントの装飾が作りにくくなる、ということなのです。例えばアウトラインを描画する場合には、頂点を複製して位置をずらして...と、uGUI Textの世代に逆戻りした手間とCPU/GPUコストが発生してしまうわけです。

 また多チャンネルの情報が必要となりますので、テクスチャサイズは数倍に膨れ上がります。折角の小さい解像度(=省サイズ)でも拡大に耐えうるというウリも弱くなってしまうのです。じゃあsingle-channelの通常のSDFで解像度上げた方が装飾も付けられるし良くね?ってなりますよね。

 もちろん、拡大に強いというメリットはより強固になるわけですから「使いみちが無い」とまでは言いませんが、以上のような理由から現在のところ主流の手法とはなっていません。

そして、TextMesh Pro

 TextMesh Proはsingle-channelのSDFフォントを描画手法としたテキスト描画システムです。uGUI Text同様にシンプルに扱えることを目指しているようで、1pass描画で全てを完結させています。
簡単かつ便利であることは間違いないのですが、その代償として、以下のような問題点があります。

問題点1:文字同士の重なりを考慮していない

textmesh-gif-2.gif
https://blogs.unity3d.com/jp/2018/10/16/making-the-most-of-textmesh-pro-in-unity-2018/ より引用)

 それぞれの文字同士の表示プライオリティが明確に破綻しています。後の文字が基本的には優先されて描かれているようですが、ハイライトのアニメーションによっては前の文字が上にあるように見えたり、挙動がとても怪しいです。(この画像を公式ブログに載せてしまうのか、と最初に読んだ当時とても衝撃を受けました。正直は美徳ですが、あまりにも正直過ぎるといいますか...)

問題点2:半透明が破綻する

clip_a1.png
 アウトラインを単色にして、アウトラインが重なる程度の衝突に抑えれば、前述のような見た目の破綻はバレにくくなります。がしかし、今度は半透明での描画が破綻してしまいます。半透明を使ってふわっとテキストをフェードイン/フェードアウト、させたくないですか?

問題点3:ドロップシャドウが大きく動かせない

clip_12.png
 他の問題に比べたら些細なことですが、1pass描画で全てを完結させる都合上、ドロップシャドウの配置をあまり大きく動かせません。画像は最大限にずらした結果です。

というわけで

 結局のところ、TextMesh Proを用いてテキスト描画を行う際には「装飾込みでも文字同士が重ならないように注意してUI構築を行う」か、あるいは「見た目が多少破綻しても気にしない」という選択肢のどちらかを取ることになります。Unityを使う以上、後者に慣れるのが正義...という気もしますが、テキストってユーザーにとって一番目を引く部分ですから、言うまでもなく大事ですよね...と私は思うわけです...

 ので、これらの問題点を改善した、新しいテキスト描画システムを自作します。しました。
ここまで、全てただの前置きです。ここからが本題です。長いよ...

下準備

SDFフォントテクスチャの用意

 今回自作するテキスト描画システムも、TextMesh Pro同様にSDFフォントを使用します。もう21世紀も20年を過ぎたわけですから、テキスト描画にSDFフォントを使わないとかあり得ないわけです。
テクスチャは事前にフォントデータからオフラインで生成するツールを製作しました。現在のTextMesh Proはダイナミックフォントとしてランタイムでテクスチャ生成が可能ですが、そこまでは手が回っていません。
clip_9.png
 要は境界線からの符号付き距離を書き出せればそれで良いので、今回はブルートフォースで実装してしまいました。SDFフォントはその距離情報の精度が地味に大事です、1文字1文字丁寧に生成しましょう。

 ダイナミックフォントに対応するなど、高速化が必要な際には
http://webstaff.itn.liu.se/~stegu/edtaa/
こちらの資料等が参考になります。
https://techblog.kayac.com/unity_advent_calendar_2018_23
こちらのブログではテクスチャ生成をComputeShaderを用いて高速化を行っています。とても興味深いです。

フォント描画用の各種情報の用意

 テキストの描画にはそれぞれのフォント毎のパラメータが必要になります。配置/UV等の情報は当然として、
・カーニング:例えば"V"と"A"が並んだときは普段よりもスペースを詰める、といった処理
・リガチャ:例えば"f"と"i"が並んだときに"fi"をくっついた1文字にまとめる、といった処理
等々の為に、フォントファイルから情報を抽出し、ランタイムでそれを反映させなければなりません。
今回は話が膨らみすぎるので割愛しますが、とても泥臭くて辛いです。

実装

 当たり前ですがランタイム実装はUnity上で行います。バージョン・環境はまあ、最近のものであれば特に問わないのではないでしょうか。

0.SDF Font描画用テンポラリRenderTargetを用意

 画面解像度と同じサイズのRenderTexture(RGBA8_Unorm)を確保します。他で使っているバッファの使い回しで構いません。DepthTestを行うので、DepthBufferも同サイズで用意します。Depthの精度は16bitで十分で、ステンシルバッファは使いません。

1.RenderTargetのクリア

 描画前にはまずColor/Depthのクリアを行いましょう。(わざわざ書くことじゃないように見えますが、後々理由が分かります)

2.フォントをSDFテクスチャを用いて描画

 SDFフォントテクスチャを参照し、フォントの縁からの符号付き距離値を拾います。この値を元に、フォントを描画します。ここまではTextMesh Proがやっていることと変わりませんが、以下の点が異なります。

・DepthTest, 及びDepthWriteを行う

 PixelShader中でSDFの距離値を深度(SV_DEPTH)として出力し、その上でDepthTestを行います。フォントの"芯"に近い部位であるほど表示の優先順位を上げることで、アウトラインによってフォント本体が隠れてしまう...といったアーティファクトを避けることが出来ます。

・OpaqueでRenderTargetへ描画する

 BackBufferへいきなり半透明で描いてしまうと、いくらDepthTestが働いていても、文字が重なった際に見た目が破綻します。ので、RenderTargetに対して不透明で描画します。結果、最も優先度の高い部位だけが残ります。

 不透明で描きますが、アルファ値は透明度としてRenderTargetへ出力しておきます。これはコンポジット時に参照され、そのタイミングで半透明として機能します。また、乗算済みアルファを適用します(乗算済みアルファについては後述します)。

3.RenderTargetをBackBufferへコンポジット

 RenderTargetは乗算済みアルファで作りましたので、ブレンドファクターはSrc:One, Dst:OneMinusSrcAlphaでBackBufferへコンポジット描画を行います。

 このとき、参照するUVをずらしてRenderTargetのアルファ値(余裕があればデプス値も使いたいのですが...)を読み、この値を元にドロップシャドウを描画しています。ドロップシャドウ専用の描画パスは必要ありません。

 実装としては以上です。意外とシンプルにまとまりましたね。この時点では...。

結果、どんな絵が得られたか?

sdffont.gif
 緑のフォントに、白と黒の2重のアウトライン+ドロップシャドウを付けた場合はこんな見た目になります。白いアウトラインの厚みを変化させています。TextMesh Proのようにアウトラインが他の文字の本体にめり込んだりすることなく描画できていることが分かるでしょうか?まさにこれが、今回自作したテキスト描画システムでやりたかったことです。
space.gif
 字間を変えて文字同士がくっついてしまっても見た目は破綻しません。レイアウト構築もこれで楽になりますよね。
alpha.gif
 透明度もこのように調整し放題です。もちろん破綻はありません。ドロップシャドウも勝手にアルファ値に応じて薄くなってくれます。便利ですね。
ge.png
 グリフやアウトラインのどれかだけアルファ値を減らす、といった調整も自在なわけです。

補足

乗算済みアルファ(premultiplied alpha)とは

 普段はラスターオペレーション(ROP)内のブレンドファクターを通常のアルファブレンド:Src = SrcColor * SrcAlphaとしている部分に関して、シェーダ内あるいはそれ以前に、アルファ値を予めRGBカラー値に乗算しておく手法です。事前乗算アルファとも呼ばれます。

 (シェーダで行う場合は)シェーダ命令数が増え、カラー精度も少々犠牲になる可能性がありますが、バイリニアフィルタリングを通した際の透明部分と不透明部分の境界で色が漏れる/腐る問題が解決されます。UI描画・コンポジットには必須のテクニックです。
higenekoさんのブログ記事が詳細解説として非常にまとまっていて素晴らしかったのですが、消えてしまっていますね...残念です(youtube動画のみ生き残っていました: https://youtu.be/dU9AXzCabiM )。

 乗算済みアルファを利用する副次的なメリットして、加算合成とアルファブレンドをレンダーステートを切り替えずに(=コンテキストスイッチを発生させずに)共存させることができるようになります。これは古くはMetalGearSolid4にて「ブレンドバッファ」という手法でやられていたものと同等です( https://game.watch.impress.co.jp/docs/20081203/3dmg4.htm )。
Unityにはレンダーステートが切り替わらないドローコールを1つにまとめる「バッチング」という優秀な機能がありますので、それを期待する意味でも、乗算済みアルファは検討の価値が大いにあります。
glow2.gif
 今回のフォント描画でも全面的に乗算済みアルファを採用しており、ルック改善とともに、グロー(フォント周辺の発光)表現を加算合成/アルファブレンドをグラデーションで調整可能な仕組みを用意しました。

ソフトエッジ化によるアンチエイリアシング

 UI描画で大事なことは、アンチエイリアシングについての配慮です。UI描画はTemporalAAやFXAAといったポストプロセスとしてのアンチエイリアシングによる画質サポートを期待することが出来ません。また、SDFフォント描画はポリゴンエッジをなぞらないので、MSAAはエイリアシング改善に全く繋がりませんし、SSAAは負荷の面で頼ること自体がリスキーです。

 しかし、アンチエイリアシングを一切意識しないテキスト描画は非常に汚いです。例えばフォントの本体(グリフ)とアウトラインをアンチエイリアシングなしにパッキリと分けてしまうと、非常に見苦しい見た目になります。ですので、これらの間をsmoothstep()である程度クロスフェードさせて馴染ませてやることでソフトエッジ化を図り、結果としてアンチエイリアシングを実現しています。単純に静止画としての画質だけではなく、サブピクセル単位のテキスト配置/移動における時間軸エイリアシングにも良い影響があります。

ソフトエッジなし
clip_4.png
適度なソフトエッジ
clip_8.png
やりすぎソフトエッジ
clip_11.png

 このクロスフェード強度は調整できるようにしており、例えばテキストをゆっくり動かす際などはエイリアシングが目立つのでフェード強度を強めに取り、逆に動かないテキストであれば弱めにすることでシャープな見栄えを確保できます。ボケ具合とチラツキ具合はトレードオフの関係なので、アーティストさんが調整できるのがベストだと判断しています。

 ソフトエッジあり/なしでテキストをゆっくり動かした際のエイリアシング改善具合は、以下のアニメーション画像を見ていたたければ一目瞭然ではないでしょうか。AAなどは全て切った状態で、ソフトエッジのパラメータ調整以外は全て同じ条件で等倍表示しています。下の画像も微妙にカクついている...ように見えるのはキャプチャ時のフレームレートのせいで、実際は引っかかることは一切なく滑らかに動いています。
aliasing2.gif
aliasing3.gif
 さらにもう一つ、エイリアシングは改善したいがボケさせたくない、ある程度GPU演算リソースを費やしてもOK、といった状況の為に、フォント描画シェーダ内部でスーパーサンプリングを行いアンチエイリアシングを行う実装も行っています。GPUコストパフォーマンスという観点では最悪といえますので、実際に使える環境は限られてしまいますが、それでも簡単に高画質化を狙える選択肢はあって誰も損はしません。

レイヤー化

 ひとまず狙い通り、TextMesh Proの問題点を改善したテキスト描画システムを作ることができました。では、これで完成?いや、残念ながら問題はまだ残っています。次のアニメーション画像を御覧ください。
err.gif
 2つの文字列が混ざって滅茶苦茶な見た目になってしまいました。同一文字列の連続する文字同士はくっついて欲しいわけですが、連続していないそれぞれ別のテキストであるならば、くっつかずに分離して描画して欲しいわけです。というわけで、コードに修正を入れていきましょう。

 まずは「まとまって描画したい文字のリスト」を「レイヤー」としてそれぞれグルーピングします。これはテキスト描画を要求する側がレイヤーのIDとして指定します。そして描画システム側は、このレイヤー毎に今まで実装した一連の処理(RenderTargetのクリア→テキストのレンダリング→BackBufferへのコンポジット)を繰り返していきます。

 このとき、RenderTargetの全画面領域に対してバッファクリア・コンポジットを毎回行っていくのは処理負荷の面で大きな無駄になりますので、テキストの描画される範囲をダーティな領域としてAABBを予めCPU側で構築しておいて、この範囲のみバッファクリアとコンポジットを行うように修正しました。これで発生するGPUフィル負荷も最小限に抑えられるはずです。

 その結果、下の画像のようになりました。
ok.gif
 このシーンでは2つのレイヤーを用意して処理を分離させることで、意図通りの表現を得ることが出来ました!

 おおよそ満足のいく結果が得られて良かったのは良かったのですが、ただ、少々仕組みがややこしくなってしまいました。特にテキスト描画コール側にレイヤーのIDを指定させる、といった仕様はあまり美しいとは言えませんね。TextMesh Proが問題点を抱えつつも現状の仕様となっているのは、このような煩雑さを避けてなるべくシンプルに使えるようにするため、なのでしょうね。

負荷について

 PixelShaderでのSV_DEPTH出力によるDepth書き換えは、ハードウェアにもよりますが基本的にとても重たい処理になります。これはEarly Z-Cullingと呼ばれるGPU内部の最適化処理が走れなくなる為です。またコンポジットの分、pass数はTextMesh Proと比べて1pass増えて2pass(バッファクリアも含めれば3pass)描画とフィル回数は増えてしまっています。

 以上のことから、GPU負荷が微妙に気になりますね。uGUI Textでアウトラインをベタベタ追加したものよりはマシだと思いますが、TextMesh Proで変に装飾を付けていない状態よりは確実に重たいでしょう。モバイルでロースペック端末もサポート対象、となると少々厳しいかもしれません。文字の総数&フィル面積がそこまで多く大きくなければ気にしなくてよい程度だとは思いますが、モバイルのロースペックは本当にびっくりするくらいロースペックなんですよね...いずれにしても、もし実際のゲームに組み込む際は、その前にきちんとプロファイリングする必要があるでしょう。なぜ今やらないかって?答えは単純、面倒だからです。

 CPU負荷についてはuGUI Text/TextMesh Proと比べて重たくなる要素が見当たらないので問題ないでしょう。

 メモリ負荷は...ASCIIなフォントに絞って使う、程度であれば気楽に扱えそうですが、日本語やその他Unicode圏に対応しようとするとなかなかハードそうですね。ダイナミックフォントに対応すればある程度は解決しますが...。

さいごに

 いかがでしたか?今回はテキストの描画というニッチなネタでした。本当は別のネタを記事にする予定だったのですが、人生の時間配分をまたミスってしまい、仕方なく有り合わせで済ませてしまいました。

 実際にゲームアプリのタイトルに組み込む、となると、例えば禁則処理であったり、アラビア語のように逆方向に進む言語への対応だったり、考えなければならない、そして正直なところ手間とリターンが見合わない近寄りたくない問題が山積しているのがこの界隈です。

 何も考えずにテキストが描画できるuGUI TextやTextMesh Proはとても優秀で、見た目の破綻やパフォーマンス面なんかよりも重要ではあるんですよね実際には。

 それではまた、来年のアドカレ12/24に投稿できたらいいですね!

hrr.gif

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity2019.4.16/Locamotion

[環境] Unity2019.4.16 [Memo] ・PackageManager Oculus Desktop Oculus Android ・XR managementは使わない
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

PUN2の2

前回

Unityアプリを2個起動して同期がとれている事は確認できました。またphotonView.IsMine で自分のオブジェクトを判別できるようになりました。

PhotonTransformView

同期用のオブジェクト

image.png

Unreliable On Change : 値が変化した場合のみ通信する

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity Standaloneアプリケーションで使えるクラッシュレポート解析ツール

本記事は、サムザップ Advent Calendar 2020 #2 の12/25の記事です。

クラッシュレポート解析ツールをStandaloneアプリケーションでも使えるようにした時のことを執筆していこうと思います。

ツール選び

調べてみると世の中には意外と多くのクラッシュレポート解析ツールがあるんですね。
候補となったツールは以下の通りです。

ツール名 対応プラットフォーム
Smart Beat Android, iOS, Webアプリ
Firebase Crashlytics Android, iOS
Unity Cloud Diagnostics PC, Mac, Linux, Android, iOS, WebGL
App Center Diagnostics Android, iOS
Unity BackTrace Xbox, Google Stadia, PS4, iOS, Android, Windows, Mac, Linux
Splunk MINT iOS, Android, HTML5, Windows 8, Windows Phone
Crittercism iOS, Android, Windows Phone, HTML5
DeployGate iOS, Android
Bugsnag iOS, Android, macOS, Windows, WebGL
RAYGUN Windows Desktop, Mac, iOS, Android
Countly iOS, Android, Windows
InstaBug iOS, Android

Windowsに対応されているもので、Unityプラグインがあって、料金もそこそこで、レポート数の上限がそこそこ多くて、Smart Beatと同じような機能を備えてて…(けっこう条件が厳しい)
最終選考まで通過したのはBugsnagRAYGUNで、最終的に選んだのはBugsnagの方でした!
iOS, Android, macOS, Windows, WebGLに対応されており、必要だったのはWindowsですが、将来Macも対応することになっても安心。

今回はそんなBugsnagの使い方をご紹介します。

Bugsnag紹介

ダッシュボードを開くとレポートの一覧が現れます。同じレポートはまとめて表示してくれてます。

レポートを選択すると、まとめられたレポートが全件表示されます。
レポート毎にスタックトレースや端末情報、ユーザ情報が確認でき、パンくずも見れます。
パンくずはアプリ側で自由に設定できます。

Bugsnag実装

まずはUnityパッケージ入手

BugsnagトップページからPricesを選択すると各プランごとのお値段が現れます。
LiteプランでもStandardプランでもどちらでも良いので「START FREE TRIAL」ボタンを押してメールアドレスなどを登録するとUnityパッケージとAPIキー(後述)が入手できます。

準備をしよう

UnityパッケージをインポートしたらUnity側でレポートを通知するための準備をしていきましょう。
BugsnagのUnityガイドがあるので簡単です。
英語だけど画像もあり、すごくわかりやすい。

GameObjectを作成します。

作成したGameObjectにBugsnagBehaviourをAddComponentします。

BugsnagBehaviourのインスペクタにAPIキーを登録します。
APIキーはBugsnagのアカウントを登録した際に発行されます。

GameObjectを作らずコードからでもBugsnagの初期化が行なえます。

Bugsnag.Init("your-api-key-here");

レポートを送信しよう

Excptionが発生した場合や、エラーログが出力された場合は自動的にBugsnagがレポートの送信を行います。
レポートを自動送信するログレベルはBugsnagBehaviourのインスペクタNotify Levelから設定できます。
コードからでも設定できます。

Bugsnag.Configuration.NotifyLevel = LogType.Warning;

レポートを意図的に送信することもできます。

Bugsnag.Notify(new System.Exception("Non-fatal"));

カスタマイズしてみよう

送信するレポートをカスタマイズすることができます。
以下の方法でレポートに任意の情報を付加させることができます。

Bugsnag.Metadata.Add("system", new Dictionary<string, string>() {
  { "subsystem", "Player Mechanics" }
});

パンくずを使ってみよう

レポートが送信されるまでにどのような遷移をたどったのかを設定したりできます。
使用用途は自由なので任意の情報を残すことができます。

Bugsnag.LeaveBreadcrumb(
  "Button clicked",
  BreadcrumbType.Navigation,
  new Dictionary<string, string>() {{ "panel", panel.Name }}
);

ユーザーIDを設定しよう

各レポートの影響を受けるユーザ数を調査するのに役立ちます。
デフォルトで一意のIDが設定されますが、ソシャゲであればおそらくユーザーを一意に識別するIDが存在するかと思います。
ゲーム内でのユーザーIDを設定しておけば、対象のユーザーに起きた事象を調査することができます。

Bugsnag.User.Id = "userId";

最後に

Bugsnagいかがでしたでしょうか?
自分は実装してみてなんて簡単なんだ!と驚きました。

SmartBeatより劣る点としてはスクリーンショット撮影機能がないことですね。
スマホのゲーム制作を開始する段階でPCでの配信も視野に入れている場合はBugsnagを検討してみても良いかと思います。

※説明を端折った部分もあるのでもっと詳しく知りたい方は公式ドキュメントを参考にしてみてください。

以上となります。
メリークリスマス&良いお年を!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】シーンビューが重い時はGizmosを切る

KAGURA UnityChanで遊んでいた時のこと。エディタの動作が重くて作業が出来ない。何か軽くする方法はないかなと思い調査しました。

Gizmosが重い

Gizmosの描画はめっちゃ重いです。

ドラッグとかすると、Gizmosのリペイント処理が走り、全体でCPU負荷が270msほどになります。


※ピンク色の箇所がシーンビューをドラッグした時の負荷

GizmosをOFFにする

Gizmosを切りました。

4ms未満まで負荷が下がり、約6700%早くなりました。
※あくまで僕の環境での話です

とても非常に快適になりました。

まとめ

シーンビューで何かしら作業していて動作が重い場合は、とりあえずGizmosを切れば軽くなる可能性があります (作業しやすいかどうかは別)

またシーンビューを複数開いている、ゲームビューを開いていると、その分CPU/GPU共に負荷がかかるため、不要なビューを閉じると動作が軽くなるかも知れません。

以上

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Unity2020.2から追加されたシーンテンプレートはとても便利

Unity2020.2からシーンテンプレートという機能が追加されています。


シーンの設定を予めテンプレートとして保存でき、それを呼び出すことができます(まさにQoL!!)。サムネも設定できて分かりやすいです。

そんなシーンテンプレートの使い方を説明していきます。

現在開いているシーンをテンプレート化する


File > Save As Scene Template...を選択して、シーン名.scenetemplateという形で保存します。

以上です。

テンプレートの呼び出し方法

いつものように新規シーン作成 (⌘ + N)


するとNew Sceneウィンドウが表示され、そこからテンプレートを選択できるようになっています。

便利です。

シーンテンプレートの編集

先のNew Sceneウィンドウ内のEditををクリック。

するとScene Template Assetが選択されるので、その中の中身を編集してくだけです。
特にシーンのサムネールをつけると分かりやすくなります。

そんな時はTake Snapshotをクリックするだけで撮影されますのでオススメ。撮影した画像はScene Template Asset内に格納されます。

まとめ

この機能を使うことで、シーンでゲームのステージを管理している場合、量産時に活躍します。今までであればTemplateとなるシーンを複製して使っていたと思います。

間違えてTemplateシーンをを上書き保存しちゃったってこともあるかも知れません。
そういうオペミスが減るワークフローが構築できそうですね。

Unity2020.2は細かく色々改善してて個人的にとても良いと感じています。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

AltUnity Tester 入門

こちらは 自動テスト Advent Calendar 2020 の 22日目の記事です。

概要

   

AltUnity Tester(AUT)は、Unityゲーム用でE2EのUIテスト自動化ツールです。

Unityでテストと言えば、元 Unity Test Runner の Unity Test Framework(UTF)がありますが、UTFはUnitテスト専用で、AUTはUIテスト専用のテストツールです。

本記事では、AUTを用いてUnityでUIテストの自動化がどのようにできるのかを解説していきます。

ターゲット

本記事のターゲットは、以下の通りです。

  • Unity でアプリ開発している人
  • Unity で初めてテストについて知りたい人
  • Unity で UIテストを自動化したい人
  • Unity Test Framework との使い分けを知りたい人

どっちのテストが良いのか

 

一言でテストと言っても用途によって様々なテストがあります。
今回の「UIテスト」「Unitテスト」の違いですが、テストが入るレイヤー(場所)が異なり、対応の早さやコストも変わります。

UTFは、UTFがサポートしているNUnitに従ってC#でテストコードを書いて、クラスやメソッドなどのプログラミングレイヤーから品質チェックを実施します。

AUTは、AUTがサポートしているC#/Python/Javaでテストコードを書いて、Unityエディター上やiOS/Androidにビルドされたアプリなどのユーザーインターフェースレイヤーから品質チェックを実施します。

この違いは、テスト範囲とテスト品質が大きく左右されます。
理想は、両方のテストをやっておくことでユーザーにほとんど不具合を出さない高度な品質管理ができるものの、時間とお金のコストが大きくなるためトレードオフとなります。アプリが必要最低限動くこと(普通に操作してアプリがクラッシュしないなど)だけ確認したいぐらいだったらAUTだけで良いと思います。

UTFをやりたい人は、Zenjectを用いることが前提になってしまいますが、ZenjectとUTFを組み合わせたUnitテストをオススメします。

AUT入門

 

AUTの各ページは以下のリンクとなります。

 
公式のリリースノートが見当たらず、Forum の AltUnity Tester - free UI end-to-end test automation tool for Unity Games およびAsset Store のリリースノートでリリース状況を把握できるようです。

プラン

公式ページ上のプランを確認するとAsset StoreからインポートするOSS版は完全無料のようで、MacOS/WindowsのAUTアプリがあってそちらは €18/月(約¥2,300ぐらい) のようです。

  • AltUnity Tester(Free)
    • UnityでUIテストができる
  • AltUnity Inspector(€18/月)
    • Unityエディターを起動しなくてもUIテストができる
    • ビルドしたアプリの中にアクセスして操作ができテストの詳細が把握できる
    • 2営業日以内の優先サポート
  • AltUnity Pro(近日リリース予定)
    • コンソールおよびWebGLゲームでテスト実行が可能に
    • テストレポートを生成する
    • クラウドサービスと統合可能に
    • 拡張サポート

プランからわかることですが、AUTはWebGLをまだサポートできていません。
また、AltUnity Inspector(AUI)の方が充実していますが、もし誰も使っていないMacPCがあれば、Unityエディター/iOS/AndroidでUIテストを自動化できるマシーンが作れるのでAUTで十分だと思います。

Example

  

まずは、新規プロジェクトからAUTだけをインポートし、サンプルを触りながら操作方法を把握していきます。
細かい手順は、公式の Get Started ページを確認しましょう。

  

AUTの基本操作は、以下の通りです。
通常の Unity 実行では UIテストは機能せず、AUTメニューから Unity を実行する必要があります。

  1. ツールバー > Window > AltUnityTester クリック
  2. AUT メニューの右サイドでUIテストしたい Platform を選択
  3. Play in Editor をクリックして実行
  4. Run All Tests / Run Selected Tests をクリックしてテスト開始
  5. 自動でテストされ結果がダイアログに表示され正常/異常の結果を AUT メニューから確認することができる

テストコード

では、実際に AUT のテストコードを書いてみましょう。
今回は、公式が YouTube にアップしているチュートリアル動画を参考にしていきます。

 

まず、練習用にシーン遷移とテキストが変化する簡単なプログラムを作ってみました。
このプログラムに対して次のようなUIテストを設計しようと思います。

  1. 最初の画面(Startシーン)で設置されているボタンをクリックして次の画面へ遷移することを確認
  2. (Gameシーンで)カウントダウンが実行され3・2・1の後はカウントダウンのテキストが消えて代わりにメッセージが表示されることを確認

 
そして、 Assets/Tests/Editor のようにフォルダーを作り、右クリック/+ボタンからメニューを開いて Create > AltUnityTester を選択してテストコードのC#ファイルを生成します。

StartSceneTest.cs
using NUnit.Framework;

public class StartSceneTest
{
    public AltUnityDriver AltUnityDriver;

    [OneTimeSetUp]
    public void SetUp()
    {
        AltUnityDriver = new AltUnityDriver();
    }

    [SetUp]
    public void LoadLevel()
    {
        // Startシーンを起動
        AltUnityDriver.LoadScene("Start", true);
    }

    [OneTimeTearDown]
    public void TearDown()
    {
        AltUnityDriver.Stop();
    }

    [Test]
    public void TestStartButtonLoadsGameScene()
    {
        // GoButtonを見つけたらクリックする
        AltUnityDriver.FindObject(By.NAME, "GoButton").ClickEvent();
        // Mainシーンに遷移することを確認
        AltUnityDriver.WaitForCurrentSceneToBe("Game");
    }
}
GameSceneTestcs
using System.Threading;
using NUnit.Framework;

public class GameSceneTest
{
    readonly string MESSAGE_SHOW = "Start !!!";
    public AltUnityDriver AltUnityDriver;

    [OneTimeSetUp]
    public void SetUp()
    {
        AltUnityDriver = new AltUnityDriver();
    }

    [SetUp]
    public void LoadLevel()
    {
        // Startシーンを起動
        AltUnityDriver.LoadScene("Game", true);
    }

    [OneTimeTearDown]
    public void TearDown()
    {
        AltUnityDriver.Stop();
    }

    [Test]
    public void Test()
    {
        //AltUnityDriver.FindObject(By.NAME, "CountDown");
        //AltUnityDriver.FindObject(By.NAME, "Message");

        Thread.Sleep(3100);
        var countDown = AltUnityDriver.FindObject(By.NAME, "CountDown").GetText();
        Assert.AreEqual(countDown, string.Empty);
        var message = AltUnityDriver.FindObject(By.NAME, "Message").GetText();
        Assert.AreEqual(message, MESSAGE_SHOW);
    }
}

 
 

 
2のカウントダウンのところがどうやってテストコードを作るのかわからなかったので、3秒後の結果だけを確認するテストコードになってしまいました笑汗
テストコードが完成し、 AUT メニューからテストコードを選択して実行してみると正常テストであることを確認しました。

課題

上記以外の実装方法について、API Documentation — AltUnity Tools documentation を調べながらAssets/AltUnityTester/Examples/Test/Editor 配下のテストコードを参考に作っていくことで早々にテストコード作りが慣れるのではないでしょうか。

ただ、今回のカウントダウンとその後に文字が表示されるなどの後から値が変わる要素を正常か確認するテストコードを作ることが難しかったため、単にリファレンスでそこをサポートしてくれるものを見落としているのか工夫が必要なのか、引き続き調査していろんなテストコードがさくっと書けるようにキャッチアップを続けたいと思いますー

C#以外のテストコード

以前、古いAUTを扱ったことがありまして、その際はPythonでテストコードを作って検証していました。
もし、Pythonで作られたい方は、MacでPythonの2.xと3.xを共存させるためにpyenvを入れた時の話し - @gremito も参考になると幸いです。

まとめ

これをきっかけに Unity で UIテストができるようになると思います。
Unity を起動し、AUTメニューからしか UIテストできないため、全テストを自動化することはできません。

ですが、それは Unity だけではできないという話で、コマンドから Unity を起動し、 PyAutoGui を用いて AUT メニューの操作を実行するPythonスクリプトを書くなどして自動化することができます。
また、テスト結果も同様でレポート機能がありませんが、例えばテスト結果をスクショしてSlackにアップするツールを組み合わせることで、テスト全行程を自動化することも不可能ではありません。

これを機に Unity で自動テストにチャレンジしてみてくださいー

 

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

全集中・Magic Leapの呼吸 弐拾参ノ型 線点 "はんど ぽいんたぁ"

この記事はMagic Leap Advent Calendar 2020 23日目の記事です。

はじめに

MagicLeapにはハンドトラッキングがあり現在ベータ版で設定からハンドポインターがあります

MagicLeapの設定 > System > Inputs ( 左側のリスト ) を開きEnable Gesture( Beta )を有効にするとホーム等でハンドポインターが利用できます。
但し、今現在 MagicLeap Tool-Kit 等にはまだその手のコンポーネントは提供されていないため自作のアプリ等でハンドポインターを利用したい場合は自作する必要があります ( MixedRealityToolKitは試していないので今回は紹介を省きます )

ML_20201220_22.38.08.jpg

image.png
image.png

やっぱね、コントローラを使わずに手で入力するのってなんかイイんですよ...

失敗作

MagicLeap Tool-KitのサンプルにControlPointerがあるのでそれをそのままHandTrackingにあてがえばいけるんじゃね?という安直な発想で試してみた結果ハンドトラッキングのノイズでポインターが暴れてしまってうまくいかなかった。

MagicLeap-Tools > Examples > ControlPointer にサンプルシーンがあります
image.png

image.png

なにを作るの?

今回作成するのはハンドポインターを表示し、こちらの全集中・Magic Leapの呼吸 肆ノ型 手入力 "かすたむ じぇすちゃ"で作成したプロジェクトを流用してRayCastがヒットしたところにオブジェクトを配置するサンプルを作るところまでです。
機能としてはまだ足りないところがありますがひとまず今回はここまでを作成します。

なお今回作成するサンプルはすべてZeroIterationでのテストまでを行っています、実機上でテストする際はCertifiedファイルの作成などを別途行う必要があります。

開発環境

リポジトリ

  • Lumin SDK : 0.24.1
  • MagicLeap Unity Package : 0.24.1 ( 少し古いけど Lumin SDKと合わせました、 0.24.2はUnity2020以降で利用できます )
  • MagicLeap Tool-Kit : MagicLeapToolKitのリポジトリ最新版
  • UniRx : 7.1.0
  • UniTask : 2.0.26

シーンの構成

今回のシーンの構成はこのようになってます、新たにHandPointerシーンを作成しポインターのヒットをとるBoxとポインタで選択した座標に配置するTargetオブジェクトを配置しています。
CameraRigはカスタムジェスチャで作成したものを流用します

image.png

スクリプト

今回作成したスクリプトは以下の通り

スクリプト名 解説
HandPointer.cs HandPointerの本体
HandPointerSelect.cs HandPointerで選択した対象をカプセル化したクラス
HandPointerCursor.cs ポインタのカーソル
IHandPointer.cs HandPointerへのインターフェース
IHandPointerCursor.cs HandPointerCursorへのインターフェース
HandPointerTest.cs テストスクリプト、ポインタで選択した際のイベント処理を登録とかする

メイン所のスクリプトをここに記述します、詳細はリポジトリに置いてあるので参考になればと


HandPointer.cs
using MagicLeapTools;
using UnityEngine;
using UnityEngine.Events;
using UnityEngine.XR.MagicLeap;

namespace AdventCalendar.HandPointer
{
    /// <summary>
    /// ハンドトラッキングでのポインター.
    /// こいつだけで両手分の処理を行う.
    /// </summary>
    public class HandPointer : MonoBehaviour, IHandPointer
    {

        #region --- class SelectEvent ---
        /// <summary>
        /// ハンドポインタで選択したイベント.
        /// </summary>
        private class SelectEvent : UnityEvent<HandPointerSelect> { }
        #endregion --- class SelectEvent ---


        #region --- class PointerPosition ---
        /// <summary>
        /// ポインタのカーソル座標.
        /// </summary>
        private class PointerPosition
        {
            public Vector3 Target { get; private set; } = Vector3.zero;
            public Vector3 LastTarget { get; private set; } = Vector3.zero;
            public Vector3 Start { get; private set; } = Vector3.zero;
            public Vector3 LastStart { get; private set; } = Vector3.zero;


            public void SetTarget(
                Vector3 position)
            {
                LastTarget = Target;
                Target = position;
            }


            public void SetStartPosition(
                Vector3 position)
            {
                LastStart = Start;
                Start = Vector3.Lerp(LastStart, position, 0.5f);
            }

        }
        #endregion --- class PointerPosition ---


        // Pointerのステート.
        public enum HandPointerState
        {
            None,
            NoSelected,
            Selected,
        }


        [SerializeField] private Transform mainCamera;
        [SerializeField] private float speed = 1f;
        [SerializeField] private GameObject cursorPrefab; // ポインターの先端に配置するカーソルのプレハブ,設定されていなければ利用しない.
        [SerializeField] private float eyeTrackingRatio = 0.3f;

        public float PointerRayDistance { get; set; } = 2f;
        public MLHandTracking.HandKeyPose SelectKeyPose { get; set; } = MLHandTracking.HandKeyPose.Pinch;
        public MLHandTracking.HandKeyPose RayDrawKeyPose { get; set; } = MLHandTracking.HandKeyPose.OpenHand;
        public HandPointerState LeftHandState { get; private set; } = HandPointerState.None;
        public HandPointerState RightHandState { get; private set; } = HandPointerState.None;

        private SelectEvent onSelect = new SelectEvent();
        private SelectEvent onSelectContinue = new SelectEvent();

        private PointerPosition leftPointerPosition;
        private PointerPosition rightPointerPosition;
        private IHandPointerCursor leftCursor;
        private IHandPointerCursor rightCursor;

        // TODO : デバッグ用パラメータ.

        // 肩幅、お好みのサイズに調整.
        [SerializeField] private float shoulderWidth = 0.2f;

        // 疑似的に決定した左右の型の位置.
        private Vector3 debugRightShoulderPosition;
        private Vector3 debugLeftShoulderPosition;

        // =========================


        /// <summary>
        /// Eyeトラッキングが有効か否か.
        /// </summary>
        private bool IsEyeTrackingValid => MLEyes.IsStarted && MLEyes.CalibrationStatus == MLEyes.Calibration.Good;

        /// <summary>
        /// 描画しているか否か.
        /// </summary>
        public bool IsShow { get; private set; } = false;


        private void Start()
        {
            if (HandInput.Ready)
            {
                HandInput.Left.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
                HandInput.Right.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
            }
            else
            {
                HandInput.OnReady += () =>
                {
                    HandInput.Left.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
                    HandInput.Right.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
                };
            }

            MLEyes.Start();

            leftCursor = new HandPointerCursor(CreateLineRenderer("LeftLineRenderer"), CreateCursor("LeftHandCursor"));
            rightCursor = new HandPointerCursor(CreateLineRenderer("RightLineRenderer"), CreateCursor("RightHandCursor"));

            leftPointerPosition = new PointerPosition();
            rightPointerPosition = new PointerPosition();
        }


        private void Update()
        {
            UpdateHandRay();

            if (LeftHandState == HandPointerState.Selected)
            {
                var result = GetSelect(MLHandTracking.HandType.Left);
                if (result.Item1)
                    onSelectContinue?.Invoke(result.Item2);
            }

            if (RightHandState == HandPointerState.Selected)
            {
                var result = GetSelect(MLHandTracking.HandType.Right);
                if (result.Item1)
                    onSelectContinue?.Invoke(result.Item2);
            }
        }


        /// <summary>
        /// HandPointerのカーソル生成.
        /// </summary>
        private GameObject CreateCursor(
            string name)
        {
            if (cursorPrefab == null) return null;
            GameObject cursor = Instantiate(cursorPrefab, transform);
            cursor.name = name;
            return cursor;
        }


        private void UpdateHandRay()
        {
            if (!HandInput.Ready || !IsShow)
            {
                LeftHandState = RightHandState = HandPointerState.None;
                leftCursor.Hide();
                rightCursor.Hide();
                return;
            }
            LeftHandState = HandInput.Left.Visible ? LeftHandState: HandPointerState.None;
            RightHandState = HandInput.Right.Visible ? RightHandState: HandPointerState.None;
            leftCursor.Show();
            rightCursor.Show();

            // Rayのスタート位置計算.
            leftPointerPosition.SetStartPosition(GetRayStartPosition(HandInput.Left));
            rightPointerPosition.SetStartPosition(GetRayStartPosition(HandInput.Right));

            // ポインターの更新.
            leftPointerPosition.SetTarget(Vector3.Lerp(leftPointerPosition.LastTarget, GetCurrentTargetPosition(MLHandTracking.HandType.Left), Time.deltaTime * speed));
            leftCursor.Update(LeftHandState, leftPointerPosition.Start, leftPointerPosition.Target);

            rightPointerPosition.SetTarget(Vector3.Lerp(rightPointerPosition.LastTarget, GetCurrentTargetPosition(MLHandTracking.HandType.Right), Time.deltaTime * speed));
            rightCursor.Update(RightHandState, rightPointerPosition.Start, rightPointerPosition.Target);
        }


        private Vector3 GetCurrentTargetPosition(
            MLHandTracking.HandType type)
        {
            Vector3 tempTargetDir = Vector3.zero;
            (bool isValid, Vector3 dir) eyeTrackingDir = GetEyeTrackingNormalizedDir();
            if (eyeTrackingDir.isValid)
                tempTargetDir = eyeTrackingDir.dir;

            Vector3 start = type == MLHandTracking.HandType.Left ? leftPointerPosition.Start : rightPointerPosition.Start;
            Vector3 shoulderToHandDir = (start - GetShoulderPosition(type)).normalized;
            Vector3 dir = tempTargetDir == Vector3.zero ? shoulderToHandDir : Vector3.Lerp(shoulderToHandDir, tempTargetDir, eyeTrackingRatio).normalized;
            return start + dir * PointerRayDistance;
        }


        /// <summary>
        /// RaycastHitしたターゲットを返す、ヒットしない場合は Item2 はnullになる.
        /// </summary>
        /// <param name="ray"></param>
        /// <param name="maxDistance"></param>
        /// <returns></returns>
        private (bool, HandPointerSelect) GetRayCastHitTarget(
            Ray ray,
            float maxDistance)
        {
            RaycastHit hit;
            return Physics.Raycast(ray, out hit, maxDistance) ? (true, new HandPointerSelect(hit)) : (false, null);
        }


        /// <summary>
        /// 選択したターゲットを取得する,選択できていない場合は Item2 はnullになる.
        /// </summary>
        /// <param name="type"></param>
        /// <returns></returns>
        private (bool, HandPointerSelect) GetSelect(
            MLHandTracking.HandType type)
        {
            Vector3 start = type == MLHandTracking.HandType.Left ? leftPointerPosition.Start : rightPointerPosition.Start;
            Vector3 target = type == MLHandTracking.HandType.Left ? leftPointerPosition.Target : rightPointerPosition.Target;
            return GetRayCastHitTarget(new Ray(start, target - start), PointerRayDistance);
        }


        /// <summary>
        /// ハンドジェスチャの変更イベント取得.
        /// </summary>
        /// <param name="hand"></param>
        /// <param name="pose"></param>
        private void OnHandGesturePoseChange(
            ManagedHand hand,
            MLHandTracking.HandKeyPose pose)
        {
            switch (hand.Hand.Type)
            {
                case MLHandTracking.HandType.Left:
                    LeftHandState = pose == SelectKeyPose ? HandPointerState.Selected : HandPointerState.NoSelected;
                    if (LeftHandState == HandPointerState.Selected)
                    {
                        (bool, HandPointerSelect) result = GetSelect(MLHandTracking.HandType.Left);
                        if (result.Item1)
                            onSelect?.Invoke(result.Item2);
                    }
                    break;

                case MLHandTracking.HandType.Right:
                    RightHandState = pose == SelectKeyPose ? HandPointerState.Selected : HandPointerState.NoSelected;
                    if (RightHandState == HandPointerState.Selected)
                    {
                        (bool, HandPointerSelect) result = GetSelect(MLHandTracking.HandType.Right);
                        if (result.Item1)
                            onSelect?.Invoke(result.Item2);
                    }
                    break;
            }
        }


        /// <summary>
        /// LineRendererを作成.
        /// </summary>
        /// <param name="name"></param>
        /// <returns></returns>
        private LineRenderer CreateLineRenderer(
            string name)
        {
            var ret = Instantiate(new GameObject(name), transform).AddComponent<LineRenderer>();
            ret.startWidth = 0.01f;
            ret.endWidth = 0.01f;
            ret.enabled = false;
            return ret;
        }


        /// <summary>
        /// 親指の根元と人差し指の根元の中間をスタートポイントとする.
        /// </summary>
        /// <param name="hand"></param>
        /// <returns></returns>
        private Vector3 GetRayStartPosition(ManagedHand hand) 
            => Vector3.Lerp(hand.Skeleton.Thumb.Knuckle.positionFiltered, hand.Skeleton.Index.Knuckle.positionFiltered, 0.5f);
            //=> hand.Skeleton.HandCenter.positionFiltered;



        /// <summary>
        /// Eyeトラッキングの方向を取得.
        /// </summary>
        /// <returns></returns>
        private (bool isValid, Vector3 normalizedDir) GetEyeTrackingNormalizedDir()
        {
            if (!IsEyeTrackingValid) return (false, Vector3.zero);

            bool isBlink = MLEyes.LeftEye.IsBlinking || MLEyes.RightEye.IsBlinking;
            if (isBlink) return (false, Vector3.zero);

            // Eyeトラッキングが有効ならEyeトラッキングの向きで補正する.
            float leftConfidence = MLEyes.LeftEye.CenterConfidence * -0.5f;
            float rightConfidence = MLEyes.RightEye.CenterConfidence * 0.5f;
            float eyeRatio = 0.5f + (leftConfidence + rightConfidence);
            return (true, Vector3.Lerp(MLEyes.LeftEye.ForwardGaze, MLEyes.RightEye.ForwardGaze, eyeRatio).normalized);
        }


        /// <summary>
        /// 頭の位置から推定した肩の座標を取得.
        /// </summary>
        /// <param name="type"></param>
        /// <returns></returns>
        private Vector3 GetShoulderPosition(
            MLHandTracking.HandType type)
        {
            Vector3 headPosition = mainCamera.position;
            Vector3 shoulderPosition = headPosition + (mainCamera.right * (type == MLHandTracking.HandType.Left ? -shoulderWidth : shoulderWidth)) + (-mainCamera.up * 0.15f);

            if (type == MLHandTracking.HandType.Left)
                debugLeftShoulderPosition = shoulderPosition;
            else
                debugRightShoulderPosition = shoulderPosition;

            return shoulderPosition;
        }


        private void OnDrawGizmos()
        {
            // 推定の肩の位置を表示.
            Gizmos.color = Color.blue;
            Gizmos.DrawWireSphere(debugLeftShoulderPosition, 0.1f);
            Gizmos.color = Color.red;
            Gizmos.DrawWireSphere(debugRightShoulderPosition, 0.1f);
        }


        /// <summary>
        /// 選択のイベントハンドラを登録.
        /// </summary>
        /// <param name="callback"></param>
        public void RegisterOnSelectHandler(
            UnityAction<HandPointerSelect> callback)
        {
            if (onSelect == null)
                onSelect = new SelectEvent();
            onSelect.AddListener(callback);
            Debug.Log($"Count : {onSelect.GetPersistentEventCount()}");
        }


        /// <summary>
        /// 長選択のイベントハンドラを登録.
        /// </summary>
        /// <param name="callback"></param>
        public void RegisterOnSelectContinueHandler(
            UnityAction<HandPointerSelect> callback)
        {
            if (onSelectContinue == null)
                onSelectContinue = new SelectEvent();
            onSelectContinue.AddListener(callback);
        }


        /// <summary>
        /// HandPointerを有効化.
        /// </summary>
        public void Show() => IsShow = true;


        /// <summary>
        /// HandPointerを無効化.
        /// </summary>
        public void Hide() => IsShow = false;
    }
}

ハンドポインタのメイン所の処理、このスクリプト一つで両手分処理してしまいます。

初期化処理
先ずはStart()で初期化を行います、ジェスチャを利用するためHandInputの初期化確認を行い、初期化されていなければ初期化完了時イベントに処理を登録します、この辺はUniTaskで待機したりするのもよいかもしれません。

登録している処理は左右の手からのジェスチャ変更イベントのリスナーです。

            if (HandInput.Ready)
            {
                HandInput.Left.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
                HandInput.Right.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
            }
            else
            {
                HandInput.OnReady += () =>
                {
                    HandInput.Left.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
                    HandInput.Right.Gesture.OnKeyPoseChanged += OnHandGesturePoseChange;
                };
            }

今回は僕の個人的な好みでEyeトラッキングも利用するので MLEyes.Start() でEyeトラッキングを起動します。

            MLEyes.Start();

そして左右のカーソルの座標更新及び描画を担当するクラスを生成します。

            leftCursor = new HandPointerCursor(CreateLineRenderer("LeftLineRenderer"), CreateCursor("LeftHandCursor"));
            rightCursor = new HandPointerCursor(CreateLineRenderer("RightLineRenderer"), CreateCursor("RightHandCursor"));

            leftPointerPosition = new PointerPosition();
            rightPointerPosition = new PointerPosition();

ハンドポインタの更新処理

メインの更新処理( UpdateHandRay() )

あなたは魔法使いになれる。そう、Magic Leapならねでも紹介されていたLeapMotionのブログを参考にしておおよその肩の位置からポインタのスタート位置までのベクトルを計算してそのベクトル方向にスタート位置からRayを飛ばしています。

img

Rayのスタート位置はよくあるハンドトラッキングのスタート位置の掌ではなく人差し指の根元と親指の根元の中間に設定しています
image.png

Rayのスタート位置を手の平にした場合
ML_20201222_02.19.03.jpg

Rayのスタート位置を親指の根元と人差し指の根元の中間にした場合
ML_20201222_02.21.10.jpg

掌の中心にRayのスタート位置を設定するとHandMeshなどを利用した時に選択したいターゲットに手がかなりの割合でかぶって選択しづらくなってしまいます、そのため親指の根元と人差し指の根元に設定しました。
さらにこの二点の場合はジェスチャをした際の関節の動きにそこまで大きく左右されないためオブジェクトを選択する際のジェスチャ時にポインタが大きくぶれることも無くなります。

            // Rayのスタート位置計算.
            leftPointerPosition.SetStartPosition(GetRayStartPosition(HandInput.Left));
            rightPointerPosition.SetStartPosition(GetRayStartPosition(HandInput.Right));

Rayのスタート位置を取得する関数

        /// <summary>
        /// 親指の根元と人差し指の根元の中間をスタートポイントとする.
        /// </summary>
        /// <param name="hand"></param>
        /// <returns></returns>
        private Vector3 GetRayStartPosition(ManagedHand hand)
            => Vector3.Lerp(hand.Skeleton.Thumb.Knuckle.positionFiltered, hand.Skeleton.Index.Knuckle.positionFiltered, 0.5f);
            //=> hand.Skeleton.HandCenter.positionFiltered;


ポインタの更新処理

ポインタのRayのベクトルは肩から手までのベクトルにEyeトラッキングのベクトルをちょっと掛け合わせています( Eyeトラッキングを掛け合わせた事に深い理由はありません、個人的な好みです )
何かを選択する際は大抵目はその方向を見るため 肩 -> 手 のベクトルのみよりもより自然な位置にポインタが向くのでは?と思い入れてみました。

結果としては思ったほど良い体験にはなりませんでした、いざ実装してみると選択するものを最初は見るのですが無意識のうちにポインタの先端に視線が動き( その際選択したい座標からポインタが遠ざかる挙動になる )それを修正するために意識的にポインタから視線を逸らし選択したい座標に目を向けるため地味に疲れました。

            // ポインターの更新.
            leftPointerPosition.SetTarget(Vector3.Lerp(leftPointerPosition.LastTarget, GetCurrentTargetPosition(MLHandTracking.HandType.Left), Time.deltaTime * speed));
            leftCursor.Update(LeftHandState, leftPointerPosition.Start, leftPointerPosition.Target);

            rightPointerPosition.SetTarget(Vector3.Lerp(rightPointerPosition.LastTarget, GetCurrentTargetPosition(MLHandTracking.HandType.Right), Time.deltaTime * speed));
            rightCursor.Update(RightHandState, rightPointerPosition.Start, rightPointerPosition.Target);
        }

ポインタの先端位置を求める処理

        private Vector3 GetCurrentTargetPosition(
            MLHandTracking.HandType type)
        {
            Vector3 tempTargetDir = Vector3.zero;
            (bool isValid, Vector3 dir) eyeTrackingDir = GetEyeTrackingNormalizedDir();
            if (eyeTrackingDir.isValid)
                tempTargetDir = eyeTrackingDir.dir;

            Vector3 start = type == MLHandTracking.HandType.Left ? leftPointerPosition.Start : rightPointerPosition.Start;
            Vector3 shoulderToHandDir = (start - GetShoulderPosition(type)).normalized;
            Vector3 dir = tempTargetDir == Vector3.zero ? shoulderToHandDir : Vector3.Lerp(shoulderToHandDir, tempTargetDir, eyeTrackingRatio).normalized;
            return start + dir * PointerRayDistance;
        }

メイン所としてはこんな感じです、これのさらにプロトタイプの場合は 肩 -> 手 のベクトルではなく 頭の向きベクトルとEyeトラッキングのベクトルを掛け合わせたものでしたがそちらよりは良い結果となったと思います。

ちなみに↓こちらがそのプロトタイプの動画です。


HandPointerCursor.cs
using UnityEngine;

namespace AdventCalendar.HandPointer
{
    /// <summary>
    /// HandPointerのカーソル.
    /// </summary>
    public class HandPointerCursor : IHandPointerCursor
    {

        private LineRenderer lineRenderer;
        private GameObject cursor = null;

        private bool IsValid => lineRenderer != null || cursor != null;



        public HandPointerCursor(
            LineRenderer _lineRenderer,
            GameObject _cursor)
        {
            lineRenderer = _lineRenderer;
            cursor = _cursor;
            lineRenderer.material = cursor.GetComponent<MeshRenderer>().material;
        }


        public void Update(
            HandPointer.HandPointerState state,
            Vector3 startPosition,
            Vector3 endPosition)
        {
            if (!IsValid) return;

            if (state == HandPointer.HandPointerState.None)
            {
                Hide();
                return;
            }
            Show();

            RaycastHit hit;
            var ray = new Ray(startPosition, endPosition - startPosition);
            if (Physics.Raycast(ray, out hit, Vector3.Distance(startPosition, endPosition)))
                endPosition = hit.point;

            lineRenderer.SetPositions(new []{startPosition, endPosition});
            cursor.SetActive(true);
            cursor.transform.SetPositionAndRotation(lineRenderer.GetPosition(lineRenderer.positionCount - 1), cursor.transform.rotation);
        }


        public void Hide()
        {
            if (!IsValid) return;

            lineRenderer.enabled = false;
            cursor.SetActive(false);
        }


        public void Show()
        {
            if (!IsValid) return;
            lineRenderer.enabled = true;
            cursor.SetActive(true);
        }
    }
}

ハンドポインタのカーソル処理、ポインタ先端のカーソルとRayの線の描画を担当。
こちらは特に特別な処理をしてるわけではなく単純に開始位置から終了位置までのLineRendererの描画とカーソルオブジェクトの移動、HandPointerのステートによっては表示非表示の切り替えを行ってます

後はCameraRigのHandControllerにHandPointerをアタッチし、パラメータの調節を行うと完成です。
image.png


HandPointerTest.cs
using UnityEngine;

namespace AdventCalendar.HandPointer
{
    /// <summary>
    /// テスト用のスクリプト.
    /// </summary>
    public class HandPointerTest : MonoBehaviour
    {
        [SerializeField] private HandPointer pointer;
        [SerializeField] private Transform targetObj;


        private void Start()
        {
            if (pointer != null)
            {
                pointer.RegisterOnSelectHandler(OnSelectHandler);
                pointer.RegisterOnSelectContinueHandler(OnSelectContinueHandler);
            }
            pointer.Show();

        }


        private void OnSelectHandler(
            HandPointerSelect target)
        {
            Debug.Log($"target : {target.Object.name}");
            targetObj.position = target.Position;
        }


        private void OnSelectContinueHandler(
            HandPointerSelect target)
        {
            Debug.Log($"target : {target.Object.name}");
            targetObj.position = target.Position;
        }

    }
}

あとはテスト用スクリプトでポインタの選択イベントの購読用リスナーを登録するだけで動きます、現在はセレクト、セレクト中の二つのイベントがあり呼ばれた際にTargetのオブジェクトをその位置に配置しています。

出来たやつ

あとがき

初のアドカレ投稿での拙い文章を読んでいただきありがとうございます。

今はまだ簡単な選択くらいしかできないのでこれからさらに機能追加していこうかなと思ってます、その時はまた別の記事でアップグレード版を紹介する記事になるかも。
次世代機のMagicLeapのハンドトラッキングの精度がさらに向上してたらハッピーだなぁ

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Unity】ExecuteInEditModeは将来廃止、ExecuteAlwaysへ置き換えていく必要あり

とあるソースコードを読んでいて、見慣れないアトリビュートを見かけました。

[ExecuteAlways]

調べてみると、ExecuteInEditModeの代わりとなるアトリビュートでした。

ExecuteInEditModeがPrefabModeが実装されるUnity2018.2以前のものだったため、PrefabModeを考慮する上で新しいアトリビュートが必要になったとのこと。

This attribute is being phased out since it does not take Prefab Mode into account. If a Prefab with a MonoBehaviour with this attribute on is edited in Prefab Mode, and Play Mode is entered, the Editor will exit Prefab Mode to prevent accidental modifications to the Prefab caused by logic intended for Play Mode only.

Unity - Scripting API: ExecuteInEditModeより

公式マニュアルの通りExecuteInEditModeは廃止されていきます。
早めの置き換えを。

最後に

2020年も残りわずか。
今年もアドベントカレンダー書きました。

【Unity】新規ゲームのUI開発で気をつけた39のTips前編 - Qiita
【Unity】新規ゲームのUI開発で気をつけた39のTips後編 - Qiita

では、良いお年を。

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

YouTube Data API Client Library for .NETをUnityで利用してYoutubeライブのコメント、スパチャを取得する

概要

Youtube公式に提供されているAPIを利用して、Youtubeライブのコメント、スパチャを取得します。
下記は作りかけの機能ですが、APIを利用するとこんなの作れます。

Youtube APIを利用出来るようにする

下記URLを参考に以下の3つを作成します。

YouTube Data API Client Library for .NETのインストール

YouTube Data API Client Library for .NETはNugetにありますが、UnityでNugetを利用するにはNuget for Unityをインストールします。
そして、Nuget for Unityで「Google.Apis.Youtube.v3」を検索してインストールします。

Oauthの実装

下記コードをGameObjectにアタッチして実行するとOAuthできます。_cliendIdと_clientSecretは↑で手に入れたものを入力します(下記コードの実行にはUniTaskのインストールが別途必要です)

YoutubeAuthorize.cs
using System.Collections.Specialized;
using System.Text.RegularExpressions;
using UnityEngine;
using System.Threading;
using Cysharp.Threading.Tasks;
using Google.Apis.Auth.OAuth2;
using Google.Apis.YouTube.v3;

public class YoutubeAuthorizeSample : MonoBehaviour
{

    [SerializeField] private string _cliendId;
    [SerializeField] private string _clientSecret;
    [SerializeField] private string _user = "SampleGame";

    private void Start()
    {
        Authorize().Forget();
    }



    async UniTask Authorize()
    {
        YoutubeAuthorize.AuthorizeAsync(_cliendId, _clientSecret, _user).Forget();
    }

    private NameValueCollection ParseQueryString(string s)
    {
        NameValueCollection nvc = new NameValueCollection();

        // remove anything other than query string from url
        if (s.Contains("?"))
        {
            s = s.Substring(s.IndexOf('?') + 1);
        }

        foreach (string vp in Regex.Split(s, "&"))
        {
            string[] singlePair = Regex.Split(vp, "=");
            if (singlePair.Length == 2)
            {
                nvc.Add(singlePair[0], singlePair[1]);
            }
            else
            {
                // only one key with no value specified in query string
                nvc.Add(singlePair[0], string.Empty);
            }
        }

        return nvc;
    }

    async UniTask<UserCredential> AuthorizeAsync(string cliendId, string clientSecret, string user)
    {
        var secrets = new ClientSecrets
        {
            ClientId = cliendId,
            ClientSecret = clientSecret
        };

        var scopes = new string[]
        {
            YouTubeService.Scope.YoutubeReadonly
        };

        return await GoogleWebAuthorizationBroker.AuthorizeAsync(secrets, scopes, user, CancellationToken.None);
    }
}

コメント、スーパーチャットの取得

下記コードをGameObjectにアタッチし、_apiKeyに↑のAPIキー、_liveChatURLにはコメントを取得したいYoutubeライブのURLを入力して実行すると、OnReceiveLiveChatMessageにコメントとスパチャの情報が渡ります。コメント取得はポーリング式で、APIから取得できるLiveChatMessageのPollingIntervalMillis間隔で取得できます。PollingIntervalMillisはサーバーの状況によって変動するらしいですが、大体が5秒程度です。

YoutubeChatProvider.cs
using System;
using System.Collections.Specialized;
using System.Text.RegularExpressions;
using UnityEngine;
using System.Threading;
using Cysharp.Threading.Tasks;
using Google.Apis.Services;
using Google.Apis.YouTube.v3;
using Google.Apis.YouTube.v3.Data;
using UniRx;
using UnityEngine.UI;
public class YoutubeChatSample : MonoBehaviour
{
    public System.Action<LiveChatMessage> OnReceiveLiveChatMessage;

    [SerializeField] private string _apiKey;
    [SerializeField] private string _liveChatURL;


    void Start()
    {
        var cancel = this.GetCancellationTokenOnDestroy();
        ConnectAsync(cancel).Forget();
    }

    async UniTask ConnectAsync(CancellationToken cancel)
    {
        var youtubeService = new YouTubeService(new BaseClientService.Initializer
        {
            ApiKey = _apiKey
        });


        var response = await GetVideoListResponse(_liveChatURL, youtubeService);

        if (response.Items.Count > 0)
        {
            var item = response.Items[0];

            var chatId = item.LiveStreamingDetails.ActiveLiveChatId;

            var livechatRequest = youtubeService.LiveChatMessages.List(chatId, new string[] { "snippet", "authorDetails" });

            livechatRequest.PageToken = null;

            var livechatResponse = await livechatRequest.ExecuteAsync();

            foreach (var liveChatItem in livechatResponse.Items)
            {
                OnReceiveLiveChatMessage?.Invoke(liveChatItem);
            }

            await UniTask.Delay((int)livechatResponse.PollingIntervalMillis);

            var nextPageToken = livechatResponse.NextPageToken;

            while (true)
            {
                if (cancel.IsCancellationRequested)
                {
                    youtubeService.Dispose();
                    break;
                }

                try
                {
                    livechatRequest.PageToken = nextPageToken;
                    livechatResponse = await livechatRequest.ExecuteAsync();


                    foreach (LiveChatMessage liveChatItem in livechatResponse.Items)
                    {
                        OnReceiveLiveChatMessage?.Invoke(liveChatItem);
                    }

                }
                catch (Exception e)
                {
                    Debug.Log(e.Message);
                }
                nextPageToken = livechatResponse.NextPageToken;
                Debug.Log((int)livechatResponse.PollingIntervalMillis);
                await UniTask.Delay((int)livechatResponse.PollingIntervalMillis, false, PlayerLoopTiming.Update, cancel);
            }

        }

    }


    async UniTask<VideoListResponse> GetVideoListResponse(string liveChatURL, YouTubeService youtubeService)
    {
        var uri = new Uri(_liveChatURL);
        var qscoll = ParseQueryString(uri.Query);
        var videoId = qscoll["v"];
        var videosList = youtubeService.Videos.List("LiveStreamingDetails");
        videosList.Id = videoId;

        try
        {
            return await videosList.ExecuteAsync();

        }
        catch (Exception e)
        {
            throw e;
        }
    }

    private NameValueCollection ParseQueryString(string s)
    {
        NameValueCollection nvc = new NameValueCollection();

        // remove anything other than query string from url
        if (s.Contains("?"))
        {
            s = s.Substring(s.IndexOf('?') + 1);
        }

        foreach (string vp in Regex.Split(s, "&"))
        {
            string[] singlePair = Regex.Split(vp, "=");
            if (singlePair.Length == 2)
            {
                nvc.Add(singlePair[0], singlePair[1]);
            }
            else
            {
                // only one key with no value specified in query string
                nvc.Add(singlePair[0], string.Empty);
            }
        }

        return nvc;
    }
}

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

VContainerを組み込んだゲームサンプル

PONOS Advent Calendar 2020 24日目の記事です。
昨日は@ackylaさんGoogleCloudShellのteachmeコマンドが便利でした。

はじめに

VContainerについての記事は以前に書いているので、そもそもVContainerってなんだろうと思った方はこちらを読んでみてください。VContainerとはどういうものなのかについて触れています。

サンプルについて

j4qf0-vk3x1.gif
今回はこのように表示されている文字を小文字から大文字へと変換するサンプルになっています。MonoBehaviourを継承した一つのクラスでも問題はないのですが、サンプルとしての有用性を高めるためにMVRPとして作成しています。またイベントのやりとりについてはUniRxを使用しています。

コード

GameSampleLifetimeScope

GameSampleLifetimeScope.cs
using VContainer;
using VContainer.Unity;
using UnityEngine;

namespace GameSample
{
    public class GameSampleLifetimeScope : LifetimeScope
    {
        [SerializeField] View view;
        [SerializeField] MessageData data;

        protected override void Configure(IContainerBuilder builder)
        {
            builder.RegisterComponent<IView>(view);
            builder.Register<ToUpperModel>(Lifetime.Scoped).WithParameter<MessageData>(data).As<IModel>();
            builder.RegisterEntryPoint<Presenter>(Lifetime.Scoped);
        }
    }
}

LifetimeScopeはこのようになっています。
ViewとModelに関してはインターフェースとして登録しています。
また、PresenterについてはEntryPointに登録をしています。

ModelについてはWithParameterを使用してLifetimeScopeに登録されているMessageDataをコンストラクタの時に渡してあげるようにしています。

MVP

ToUpperModel.cs
using UniRx;

namespace GameSample
{
    public class ToUpperModel : IModel
    {
        ReactiveProperty<string> message = new ReactiveProperty<string>();
        public ReadOnlyReactiveProperty<string> Message => message.ToReadOnlyReactiveProperty();

        public ToUpperModel(MessageData msg)
        {
            message.Value = msg.Message;
        }

        public void Modify(string msg)
        {
            message.Value = msg.ToUpper();
        }
    }
}
View.cs
using UnityEngine;
using UnityEngine.UI;
using System;
using UniRx;

namespace GameSample
{
    public class View : MonoBehaviour, IView
    {
        [SerializeField] Text text;
        [SerializeField] Button modify;

        Subject<string> modifySubject = new Subject<string>();
        public IObservable<string> OnClickModify => modifySubject;

        private void Start()
        {
            modify.OnClickAsObservable().Subscribe(_ => modifySubject.OnNext(text.text)).AddTo(this);
        }

        public void RefreshMessage(string msg)
        {
            text.text = msg;
        }
    }
}
Presenter.cs
using VContainer.Unity;
using System;
using UniRx;

namespace GameSample
{
    public interface IView
    {
        IObservable<string> OnClickModify { get; }
        void RefreshMessage(string msg);
    }

    public interface IModel
    {
        ReadOnlyReactiveProperty<string> Message { get; }
        void Modify(string msg);
    }

    public class Presenter: IDisposable, IInitializable
    {
        CompositeDisposable disposables;

        readonly IView view;
        readonly IModel model;

        public Presenter(IView view, IModel model)
        {
            this.view = view;
            this.model = model;
            disposables = new CompositeDisposable();
        }

        public void Initialize()
        {
            view.OnClickModify.Subscribe(model.Modify).AddTo(disposables);
            model.Message.Subscribe(view.RefreshMessage).AddTo(disposables);
        }

        public void Dispose()
        {
            disposables.Dispose();
        }
    }
}

Model、View、Presenterについてはこのようになっています。
ModelのコンストラクタにMessageDataが引数で渡されていますが、こちらは先ほど述べたようにLifetimeScopeにて登録されていたデータが渡されます。このコンストラクタは依存解決時に自動的に呼び出されます。

今回の肝になっているのはPresenterになっています。
PresenterもModel同様にコンストラクタは依存解決時に自動的に呼び出されてIViewIModelが引数に渡されます。Presenterが呼び出されている箇所はもちろんMVRPなのでありませんが、PresenterにIInitializableを設定し、EntryPointに登録することによって自動的に呼び出され、その時に依存解決されるのです。これにより紐づる式にModelも生成されます。
またIDisposableも継承していますが、こちらもインスタンス破棄のタイミングで自動的に呼ばれる仕組みになっています。
DIの使い方としてインターフェースを渡すことにより、修正コストを少なくして別の処理に置き換えることができます。
ToUpperModelをToLowerModelという小文字に変換するModelへと置き換えた場合の修正コストは以下になります。

ToLowerModel

ToLowerModel.cs
using UniRx;

namespace GameSample
{
    public class ToLowerModel : IModel
    {
        ReactiveProperty<string> message = new ReactiveProperty<string>();
        public ReadOnlyReactiveProperty<string> Message => message.ToReadOnlyReactiveProperty();

        public ToLowerModel(MessageData msg)
        {
            message.Value = msg.Message;
        }

        public void Modify(string msg)
        {
            message.Value = msg.ToLower();
        }
    }
}

既存コードの修正箇所

GameSampleLifetimeScope.cs(修正箇所のみ)
builder.Register<ToLowerModel>(Lifetime.Scoped).WithParameter<MessageData>(data).As<IModel>();

デバッグ用の処理と、本番用の処理の切替が楽にすみますね!
DIの利点はこの修正コストの少なさだと私は思っています。

MessageData

MessageData.cs
using UnityEngine;

[CreateAssetMenu(menuName = "Create/Create Message")]
public class MessageData : ScriptableObject
{
    public string Message;
}

渡しているMessageDataの中身はこのようになっています。

EntryPoint

先ほどのPresenterの登録についてEntryPointで行いました。
EntryPointとはPlayerLoopおよびMonoBehaviourで自動的に行われる処理と同じようなタイミングで走る処理の総称と思って間違いなと思っています。
詳しくはこちらをご確認いただければと思います。

まとめ

前回も書きましたがZenjectとコードの書き方が似ているので移行するにしてもそこまで難しくないかなと考えています。
まだまだプロジェクトへの導入はありませんがこれから増えていって欲しいなと思います。そしてどんどん記事も増えて情報も増えていって欲しいですね。
最後に公式のサイトがオープンしたらしいのでこちらで色々と使い方をみてみてください。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む