20200517のUnityに関する記事は10件です。

UnityでByte配列をデバイス間で送受信するだけのライブラリ作った(SEDSS)

SEDSS - SimpleEncryptedDataSendSample

UnityでByte配列をデバイス間で送受信するだけのライブラリアセットを作りました。
https://github.com/gpsnmeajp/SimpleEncryptedDataSendSample

詳しい仕様は、API仕様をご確認ください

これは何なの?

byte[]配列をデバイス間で送りたい時に手軽な通信ライブラリです。
ファイルを経由せず、メモリ上で送受信します。送受信データは暗号化されています。

内部ではHTTPを利用していますが、利用する際は意識せずに利用できます。
Unity間で、byte[]配列1つ渡せればそれでいいのになー、サーバーとか通信とか考えるの面倒だなー、って時に使えます

どうやって使うの?

  1. UnityCipherを入れます。https://github.com/TakuKobayashi/UnityCipher
  2. SEDSS_Server,SEDSS_Clientを適当なGameObjectにアタッチします
  3. 以下のコードを入れます。ここではテキストを送受信しています。

注意点: 送受信側でPasswordは同じものである必要があります。アドレスやポートは適時設定してください。

クライアント側
    void download()
    {
        var client = GetComponent<SEDSS_Client>();
        client.SetAddress("127.0.0.1");
        client.SetPassword("1234");

        string request_id = "test message";
        Debug.Log("Download Start ID:" + request_id);
        client.Download(request_id, (data, id) =>
        {
            Debug.Log("Download OK ID:" + id);
            Debug.Log("Message:" + new System.Text.UTF8Encoding(false).GetString(data));
        }, (e, id) => {
            Debug.Log("Download Error ID:" + id);
            Debug.Log("Message:" + e);
        });
    }
//アップロードも同様にできます。ここでは省略
サーバー側
    void Start()
    {
        var server = GetComponent<SEDSS_Server>();
        server.SetPassword("1234");
        server.StartServer();

        server.OnDataUploaded = (data, id) => {
            Debug.Log("Server Received ID:" + id);
            Debug.Log("Message:" + new System.Text.UTF8Encoding(false).GetString(data));
        };
        server.OnDownloadRequest = (id) => {
            Debug.Log("Server Send ID:" + id);
            return new System.Text.UTF8Encoding(false).GetBytes("You're welcome");
        };
    }

なぜ作ったの?

Unity内のオブジェクトを同期させたりと言った、同じアプリケーション間で通信するためのフレームワークってのはいっぱいありますよね。
でも、目的が違うアプリケーションで通信する場合はどうしましょうか?

例えば、スマホのセンサーアプリと、PCで3Dで出すアプリで、わざわざPUNとか使いませんよね。

そういう違うPCやデバイス間でUnityアプリケーション間で簡単に、ちょっとした通信をしようとすると、OSCが便利です。
私はuOSCを愛用しています。
https://github.com/hecomi/uOSC

そして軽量なデータ(1kB以内程度)のデータはまあ簡単にやり取りできるようになったとしましょう。
そこでこう思うことって結構無いですか?

  • 起動時に設定ファイルを渡したい
  • いくつか画像や音声データを渡したい
  • PC側のデータとスマホ側のデータ同期させておきたいなぁ

こういうとき、大体数百kBや2~3MBくらいあったりするものです。さすがにUDPベースのOSCで送る気にはなりませんよね。
HTTPとか使いますよね。でもサーバー立てますか?面倒ですよね。サーバー立てるのも、サーバー機能を実装するのも。

というわけで作ったのが、SimpleEncryptedDataSendSampleです。
サーバー立てるのは避けられませんが、実装するのは楽になります。

作者は具体的にどういう場面で使うの?

私は、VRデバイスのモーションデータを他のソフトに送信して利用し合うVMC Protocolを最近活用しています。
https://sh-akira.github.io/VirtualMotionCaptureProtocol/

これを利用する際は、送信側と受信側の両方で同じVRMファイル(3Dアバターデータ)を読み込む必要があります。
同じPC内で使う場合は、同じファイルを自動的に読みに行く仕組みがあるので良いのですが、WindowsとMacとか、iPhoneとPCとか、そういう別デバイス間で使おうとすると、いちいちVRMファイルをコピーするのが面倒になってきたので作りました。

VRアバターデータをやり取りする仕組み上、同じネットワークに複数人居た時に「間違って送った/受け取った」が起きると嫌なので、暗号化処理を入れて、間違った相手とつながらない&平文でパケット見られても大丈夫にしています。

どういう仕組なの?

図で表すとこんな感じです。
image.png

サーバーは.NetのHTTP Listenerを使っています。
クライアントは、UnityWebRequest(中身はcurlらしい?)です。

HTTP PUTを使って送受信しています。
通信が終わるとコールバックが発生します。
それだけです。

通信処理と暗号化処理を抽象化し、HTTPを意識すること無く使いやすくするというのがこのライブラリの目的です。
中身も別段複雑なものではないのですが、ハマりポイントは色々ありました。

先に記載の通り、単にHTTPで送受信するだけではなく、暗号化処理を入れています。
AES256(Rijndael)なので、パスワードが十分長ければ十分な暗号化が行われます。

ただ一方で、詳しくは説明しませんがプロトコルの作りが甘いため、いたずら防止程度の効果しかないと思ってください。
暗号化はあくまでおまけであり、LAN内やVPN上で使用する前提です。
また当然ですが、末端の送信者・受信者は信頼できる相手である前提です。
(メモリ上には普通に生データで置かれますので。その後どうするかは利用するプログラムによりますが)

知っておくと良いこと

IDについて

最初は本当にbyte[]配列1つだけの想定でしたが、そのうち複数の種類やり取りできる必要があるかも知れないと考えたので、idを追加しました。
idは単に文字列ですので付加情報として使うのも良いですし、ほしいファイル名を送っても良いです。
サーバーは単に無視しても良いです。

Upload/Download

スマホって基本的にサーバーになることはないじゃないですか。クライアントですよね。
PCがサーバーになります。

しかし、やり取りしたいデータは方向が決まっているとは限りません。
あるときはスマホ→PCかもしれないし、あるときは逆かもしれない。

そういうことで、このシンプルな作りでも、アップロードとダウンロード両方に対応しています。

push

残念ながらこの作りでは、サーバー側からプッシュはできません。
あくまでクライアント側が動作の起点になります。

しかしながら、pushしたくなる時があるかも知れません。
OSCなどでやり取りしているのであれば、クライアント側に別経路で要求を送るのも手ですが、
一番簡単な解決策としては、両方ともサーバーを立ててしまうという手もあります。
OSCとかそんな感じですし。

おわりに

小さなアプリで気軽に使うためのライブラリです。
大容量だったり、頻繁だったりするデータのやり取りには向いていませんが、用途が合えばとても便利だと思います。

私の最近の目標に「複雑で使いにくいものを作るくらいなら、仕様を絞ってとにかく気軽に使えるようにしたい」というのがあります。
もし気になりましたらぜひ使ってみてください。

暗号化ライブラリ

こちらを利用させていただいております。

Unity(C#) で「正しい」暗号化処理をするライブラリを作成しました
https://qiita.com/taptappun/items/1a9dbc8dc62c072aabb5

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

最近、Unityでよく使うショートカット

シーンにゲームオブジェクトを置き過ぎてしまった

Unityでゲームを作るのが楽しくて、シーンにたくさんゲームオブジェクトやらスクリプトやらを置いていたら、再生ボタンを押してから動作するのに30秒ぐらいかかるようになってしまいました。

シーンのヒエラルキーにあるオブジェクトが画面のどこにあるのかわからない時

それはさておき、シーンを作りこんでいたり、Prefabだらけだったり、何年も前に制作したシーンだったりするとどこに何を置いたのか、それが画面のどこに表示されるのか分からなくなってしまうことがあります。

もっと便利な方法があるのかもしれないけど・・・

そんな時、ヒエラルキー上のゲームオブジェクトを選択してから、SHIFT+Fを押すと、そのゲームオブジェクトが中心に表示されるようにシーンが移動してくれます。

これでマウスのホイールを行ったり来たりしてシーン上のオブジェクトを探すことがなくなりました。

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

Unity超初心者が引き出し開閉アクションをつくるその2

はじめに

こちらの勉強メモの内容のつづきです
https://qiita.com/okapy0922/items/7f2de09d0f2d8f4c1c60

今回もこちらのチュートリアルを参考にざっくり手順を残したいと思います。
https://www.youtube.com/watch?v=a5WXiMN3APk

引き出しに触れた状態で引き出しを開閉するアクションと、
開けた引き出しの中に球体が入っている状態を再現しました。

再現したもの

20200517_162037.gif

Raycastというみえないレーザー状の光線をとばして、引き出しのコライダに接触していたら開閉を行います。

スクリプト追加

FPSControllerに新しくAdd Componentでスクリプトを追加します。

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

//gameObjectにカーソルが接触していたらRaycastで触れ、開閉を行う

public class Interactive : MonoBehaviour{
    // Float型 Rayの有効範囲を値で入力
    [SerializeField] private float interactRange;

    //InteractiveObject.csを呼び出すため
    private InteractiveObject interactiveObject;

    /* カメラ型の変数cam 
    (FPSコントローラのインスペクター内を確認する、
    メインカメラとしてタグ付けされたCameraコンポーネントがある場合にRaycastが機能するようにしている)
    メインカメラがタグ付けされているオブジェクトはシーン内で1つだけにすること*/
    private Camera cam;

    // Raycast型の変数hit
    private RaycastHit hit;

    void Start(){
        // シーン開始時にメインカメラのCameraコンポーネントを変数camに取得する
        cam = Camera.main;
    }

    // Eキーを押した瞬間にRaycastを実行する
    void Update(){
        if (Input.GetKeyDown(KeyCode.E)) {
            /* Raycastの判定
             レーザービームが伸びた先までに何かにぶつかるものがあるかどうか、
             Rayの有効距離はメインカメラ(Player)の座標位置からinteractRangeに格納されている位置情報の値まで*/ 
            Physics.Raycast(cam.transform.position, cam.transform.forward, out hit, interactRange);
            // Raycastがゲームオブジェクトにhitしたら
            if (hit.transform) {
                // InteractiveObject.cs内の開閉処理をよびだす
                interactiveObject = hit.transform.GetComponent<InteractiveObject>();
            }else{
            // Raycastで触れていないときは開閉処理しない、nullを指定する
            //(これがないと引き出しに触れ開閉した後、触れてない状態でも引き出しの開閉ができてしまう)
                interactiveObject = null;
            }
            // nullであるかどうか判定処理(NullRefarenceExeptionを回避)
            if (interactiveObject) {
            // InteractiveObject.csのPerformAtionメソッドを呼び出す
            interactiveObject.PerformAction();
            }
        }
    }
}

レティクル(照準器)追加

Unity画面の左上[Gameobject]-[UI]-[Image]を押下後、
インスペクター内の[Image(Script)]-[Source Image]の右側の丸を選択し、
レティクルのデザインを選びます。[GUIReticle]がちょうどよさげだったので
これを使いました。
ゲーム画面を見ながら照準器が真ん中にくるように置いてあげます。
image.png

オーディオソース追加

こちらから引き出しの開閉音をダウンロードして使用しました。
フリー素材の効果音をダウンロードできます、種類が豊富です。
http://soundbible.com/
ダウンロードしたファイルは保存先のフォルダからUnity上のAseets内に適宜保存先を用意して
ドラッグドロップします。
[AudioClip]の丸を押下後、保存したオーディオファイルを選択し、
下側にある[play on Awake]のチェックボックスは外します。
(チェックを入れてるとゲーム起動後と同時に設定した効果音が鳴ります)

image.png

スクリプト編集

前回追加したInteractiveObject.csを編集します。

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

public class InteractiveObject : MonoBehaviour{
    // Vector3型 引き出しが開いた時(openPositon)と閉じたとき(closePosition)の座標位置をインスペクタ表示、数値入力で設定
    [SerializeField] private Vector3 openPositon, closedPosition;

    // Float型 引き出しの開閉スピードをインスペクタ表示、数値入力で設定
    [SerializeField] private float animationTime;

    /* ブーリアン型 開いているか閉じているかを「isOpen」(真偽)の値で調べている
     (これがないとEキーで開いたとき、引き出しが閉まらなくなる)
     ブール値はデフォルトの状態でfalseにする*/
    [SerializeField] private bool isOpen = false;

    // God_P.234 ハッシュテーブル:キーと値の組み合わせの情報で、
    // キーにより値を出し入れすることができる連想配列と呼ばれる配列の一種のこと
    // (これがないと引き出しを開けたとき空間に飛び出たままになった)
    private Hashtable iTweenArgs;

    // audioSource作成(オーディオクリップを再生するための変数)
    private AudioSource audioSource;

    void Start(){
        /* P.234 ハッシュテーブル作成_渡す引数(arguments)は2個ペア
        Property NameとTypeをカンマ区切りで追加する*/
        iTweenArgs = iTween.Hash();
        // GameObjectがアニメーション化する空間内のポイント。
        iTweenArgs.Add("position", openPositon);
        // アニメーションが完了するまでの秒数
        iTweenArgs.Add("time", animationTime);
        // 「islocal」ワールド空間でアニメ化するか、親(引き出し)を基準にしてアニメ化するか。
        // (デフォルト値はfalseでtrueにすることで親オブジェクトを基準としている)
        iTweenArgs.Add("islocal", true);

        // オーディオソースのコンポーネント取得(GetComponentでAudioSourceを取得)
        audioSource = GetComponent<AudioSource>();
    }

    // 他のスクリプトからアクセスが可能となるようにpublic宣言
    public void PerformAction(){
        // 引き出しが開閉されたら開閉音が再生される
        if (audioSource) {
            // 与えたオーディオクリップを取得して自動的に再生
            audioSource.Play();
        }
        // (isOpenの値が開いているか閉じているかの状態を保持している)
        if (isOpen){
        // 引き出しが開いていたら閉じる
       iTweenArgs["position"] = closedPosition;
       }else{
       // 引き出しが閉じていたら開く
       iTweenArgs["position"] = openPositon;
       }
       //  (真偽を交互に置き換えする、ここにこれがないとEボタン連打したときに引き出しの開け閉めがうまく動作しなかった)
       isOpen = !isOpen;
       // インスペクタで設定した座標位置にアニメーション移動(引き出し開閉)
       iTween.MoveTo(gameObject, iTweenArgs);
    }
}

FPSコントローラの設定変更

シーン上のFPSコントローラはカプセルコライダを設定していますが、Raycastを使用するときは
このコライダが邪魔をしてしまい開閉のアクションがうまくいかないことが起きてしまうようです。
image.png

そこでFPSコントローラの[Layer]プルダウンから[Ignore Raycast]を選択して設定しておくと
コライダの判定を無視したRaycast判定が利用できるようになります。
image.png

引き出しに物体を入れる

引き出しなので、中に何かが入っている状態を再現します。
動画内の内容に沿って引き出しの中に球体をいれました。
球体にはコンポネント追加で[Rigidbody]を設定します。
image.png

物体をいれる引き出しにはRigidBody内の[Is Kinematic]にチェックをいれます。
Is Kinematicを有効化すると物理演算の影響が有効化されるため
引き出しの中にモノが入っている状態が再現できるのと、
引き出しを開いた勢いと同時に球体がコロコロと転がってきます。
image.png

次回

動画チュートリアルの内容のつづきとなりますが、
引き出しのスライドの動きに加え、宝箱の開閉、ドア開閉の軸回転の動き(Rotate)を同シーン状に再現していきたいと思います。

参考にしたもの

https://qiita.com/4_mio_11/items/4b10c6fe37fd7a856350
http://megumisoft.hatenablog.com/entry/2015/08/13/172136
https://ekulabo.com/rigidbody-is-kinematic

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

無料でUnityをバージョン管理する!Unity事前設定編

PAKU6252_TP_V.jpg

前回バージョン管理の方法をいくつか紹介しておりましたが、Unity側に事前設定する必要がありましたので記事にしておきました。
もし問題が起きていた方はこちらを設定の上、試していただければ幸いです。

ブログ本文はこちらです。

https://tedenglish.site/how-to-manage-unity-project-4/

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

初めてのオリジナルTPSゲーム作成 〜はじめに〜

はじめに

2ヶ月かけて初めてのオリジナルゲームを作成しました。ウォッチドッグス2に憧れて、シューティング系のTPSゲームです。(ウォッチドッグスみたいにアクション・ハッキング・スタイルがかっこいい感じではありませんが。。。)
プレイ動画はこんな感じ。
動画:「初めてのオリジナルTPSゲーム(プレイ動画)」

TPSゲームは「カメラの動きとプレイヤーの動きの連動」が大事だなと感じたので、別記事で「カメラ編」「カメラとプレイヤー連動編」の大きく二編に分けて実装方法を解説します。
とりあえずこの記事は、ここまで。(笑)

ポートフォリオ
RESUMEにポートフォリオとして各機能を動画にしてあるので、よかったらみてください。
TPSゲーム。ウォッチドッグスに憧れて。

参照記事一覧

後々、参考にした記事と一緒に振り返りができるよう、自分用に載っけておきます。
他にも参考にした記事はたくさんありますが、作成過程でメモに残していたものをひとまず。

Animationを考慮したジャンプ(Animationの分割)
https://qiita.com/mkgask/items/fa307811da6d9d76bc97

AnimationEventを使いこなす(Animation中の任意のフレームにevent追加)
https://qiita.com/aimy-07/items/58e77d3396ded286affc

Animationの動きに合わせてColliderのパラメータ変更(AnimationCurvesの編集)
https://gametukurikata.com/program/changecollider

Colliderは内側には衝突判定なし
https://qiita.com/mechamogera/items/166f7486323e171356b4

rayを利用したキャラクターの接地判定(Physics.Linecast)
https://gametukurikata.com/program/scriptisgrounded

キャラクターが転ばないようにする(Freeze Rotation)
http://blog.livedoor.jp/f_paul/archives/27442792.html

ButtonのOnClick()以外・Button以外のuGUIでのイベント発生をScriptで実装(uGUIとEventTrigger)
https://tech.pjin.jp/blog/2017/09/03/unity_event-trigger/

uGUIのEventTriggerにて、PointerDownとPointerUpを使い「押下中(ボタン長押し)」を実装(EventTriggerに「押下中(ボタン長押し)」がないため)
https://qiita.com/netty/items/66284cbb2bb1cd42a486

オリジナルのジョイスティック(バーチャルパッド)を実装
https://kan-kikuchi.hatenablog.com/entry/uGUI_Joystick_1

Unity2Dで、三角形や四角形や円形など基本的なスプライトを利用する設定方法
https://chappyworld.hatenablog.com/entry/2018/09/23/123925

自分でインポートしたpng画像を利用する方法(特にuGUIにて)
https://teratail.com/questions/98383

Icons(シンプルなアイコンが無料ダウンロード可能)
https://material.io/resources/icons/?icon=fiber_manual_record&style=baseline

ワールド座標とローカル座標の変換
https://dkrevel.com/unity-explain/space

transform.Translateとtransform.Rotateはローカル座標系(ワールド座標系への変換も可能)
https://qiita.com/Teach/items/7fa4db3bbcfa5a2ccd50

Quaternionを数学でキチンと理解する
https://virtualcast.jp/blog/2019/11/quaternion/

Quarternionの使い方入門
https://www.sejuku.net/blog/55596

他のオブジェクトについているスクリプトの変数参照・関数実行
https://qiita.com/tsukasa_wear_parker/items/09d4bcc5af3556b9bb3a

スマホ画面の左/右半分タッチしていることを検知
http://thiqxis.hatenablog.com/entry/2018/02/13/222712

アニメーションクリップのプロパティを理解
https://xr-hub.com/archives/13007

RaycastのlayerMask設定方法(rayに衝突するObjectの設定)
https://kan-kikuchi.hatenablog.com/entry/RayCast2

RaycastとRaycastallの違い(Raycast returns the first thing hit by the ray - RaycastAll returns everything that was hit along the length of the ray.)
https://answers.unity.com/questions/429852/what-is-the-difference-between-raycast-and-raycast.html

ColliderのOnTrigerと、ベクトルの内積を用いた索敵
https://www.urablog.xyz/entry/2017/10/24/073654

数秒後に異なる処理の実行・中断や非同期のような処理を作成(Coroutine)
https://www.sejuku.net/blog/83712

UIゲージ(fillAmount、Filled)
http://makegameandmore.blog.fc2.com/blog-entry-7.html

敵キャラの自動追跡やナビゲーション(ナビゲーションシステム)
https://xr-hub.com/archives/7639

Unityエディタでの3Dモデル作成(ProBuilder)
https://xr-hub.com/archives/5463

連続的なカメラの移動 not 断続的(Vector3.Lerp())
https://qiita.com/aimy-07/items/ad0d99191da21c0adbc3

EventHandlerにてOnPointerUpを使う際の盲点(空でもOnPointerDownが実装されていないと呼ばれない)
https://qiita.com/butislime/items/24610330812580cf5eb2

Quaternion.FromToRotation(Vector3.up, hitInfo.normal);
(FromToRotationと.normalの使い方)
https://gametukurikata.com/program/bulletmark

2つのベクトルの角度を度数法で取得する方法(内積→ラジアン→度数法)
https://qiita.com/6janazi/items/bc7613e4806f3ac8f481

Rigidbody・Colliderを使う際の、移動摩擦と回転摩擦(RigidbodyのDragとAngularDrag)
→Anngular Dragの設定をしないと衝突時に回転してしまう

Navigationを使おう
敵の移動(基本)・NavMeshのステージへの適用/NavMeshAgentをキャラに設定
https://gametukurikata.com/navigation/navigationmove
敵の移動(巡回)
https://gametukurikata.com/navigation/movetodestination

UIはコンポーネント = ScriptではTransform型で取得(GameObject型では取得不可)
https://qiita.com/MrSmart/items/76ed7dc7cb034c607721

transform.FindとgameObject.Find等々の違い(計算量が少ない実装を選択しよう!)
https://qiita.com/hkomo746/items/1988a395020e16ef0365

deltaTimeで実機のスペック問わず値を安定させるの必須
https://yumineko.com/time-deltatime/

画面のアスペクト比・解像度に合わせてUI変更(Canvasの設定)
https://pengoya.net/unity/ui-fix/

Skyboxの変更
https://miyagame.net/skybox/

mixamoでモデルのtextureがインポートされていない/適用されていないとき
https://qiita.com/HiromuKinoshita/items/351f9f00232ef0461fc5

Camera・ObjectのOcclusion Culling(カメラに写っていない部分は描画しない)
https://gametukurikata.com/basic/occlusionculling

配列とリストの違い・配列とリストの初期化方法
https://www.fenet.jp/dotnet/column/language/713/

敵の頭の上にHPバー
https://qiita.com/sakas1231/items/9acd52828204b13c93f1

Navmeshagent利用時の注意点(他コンポーネントと共に使う際)
https://docs.unity3d.com/ja/2018.4/Manual/nav-MixingComponents.html

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

初めてのオリジナルTPSゲーム作成 〜カメラとプレイヤー連動編〜

アジェンダ

1.はじめに
2.ヒエラルキーの確認
3.コードの確認
 a.アイドル状態
 b.ピストル状態
4. おわりに

1. はじめに

この記事は、「初めてのオリジナルTPSゲーム作成 〜カメラ編〜」の続編になります。
上記記事をみていただいてからの方が、理解しやすいと思います。

今回は、カメラとプレイヤーの連動した動きの実装方法を、2.ヒエラルキーの確認、3.コードの確認に分けて解説していきます。
誰かのヒントになると嬉しいです。至らぬ点・不明点があればご指摘いただけると幸いです。

2.ヒエラルキーの確認

詳しくは「初めてのオリジナルTPSゲーム作成 〜カメラ編〜」の【2.ヒエラルキーの確認】をみていただきたいです。
プレイヤーのヒエラルキービューの画像を一枚だけ載せておきます。
2020-05-14 17.22のイメージ.jpeg
-PlayerAxis:
 プレイヤー本体(Player_TPose。3Dモデル)とカメラ(MainCamera)の親オブジェクト。
 プレイヤー本体の動きとカメラの動きを切り分けるため、両オブジェクトの親を設けた。
-Player_TPose:
 プレイヤー本体(3Dモデル)。
-MainCamera:
 カメラ。

説明は「カメラ編」に書いてあるのでほぼ丸投げしますが、一点だけ説明します。
それは、Player_TPose(プレイヤー本体)とMainCamera(カメラ)を親子関係にしていないため、プレイヤー本体の動きとカメラの動きを別々で動かせるということです。
動画で確認してみましょう。
動画:「初めてのオリジナルTPSゲーム(カメラ)」

プレイヤー本体が初期位置から左方向に向かって歩き始めましたが、カメラの向きは変わっていません。そして、プレイヤー本体を動かしながら自由自在にカメラも動かしています。
これが例えば、プレイヤーをカメラの親オブジェクトにすると、プレイヤーの動きとカメラの動きが常に同期されるため、FPSのカメラワークとなります。

3.コードの確認

コードはアジェンダの再掲になりますが、
a.アイドル状態
b.ピストル状態

に分けて説明していきます。


a. アイドル状態

アイドル状態は、ここではピストルを構えていない状態を指します。
2.ヒエラルキーの確認でみた動画にあるように、プレイヤー本体の動きとカメラの動きを別々で動せるように実装しています。
具体的には、以下のように切り分けされています。
❶画面左下のジョイスティック操作(赤枠):
   カメラから見て、傾けた方向にプレイヤー本体が向く & PlayerAxis(親オブジェクト)が移動。
❷画面上半分のPanel(UI)のドラッグ(青枠):
   プレイヤー本体を基点(厳密にはCardinalPointOfCameraを基点)にカメラの衛星回転。
   (プレイヤー方向を常に向いて、プレイヤーから常に一定の距離を保ちながら移動。)

2020-05-17 13.32のイメージ.jpeg

順番にコードを見ていきます。

❶画面左下のジョイスティック操作:
   カメラから見て、傾けた方向にプレイヤー本体が向く & PlayerAxis(親オブジェクト)が移動する。

PlayerMove.cs
//Player_TPoseにアタッチされたスクリプトです。
void Update()
{
    //JoystickとPlayerの動きを連動させるための変数を用意
    stickCurrentPos = Stick.transform.localPosition;
    stickDis = (stickCurrentPos - stickInitialPos).magnitude;
    //uGUI>Joystick>Stickの現在座標から弧度(ラジアン)を算出
    stickRadian = Mathf.Atan2(stickCurrentPos.y, stickCurrentPos.x);
    //孤度(ラジアン)を角度(θ)に変換
    stickAngle = stickRadian * Mathf.Rad2Deg; 
    //(450f - stickAngle)は、uGUI>Joystick>Stick と Player の動きを直感的に操作するための補正
    //uGUI>Joystick>StickのθはX軸から反時計回り(XY平面)・PlayerのθはZ軸から時計回り(ZX平面)
    stickRotation = Quaternion.AngleAxis((450f - stickAngle), Vector3.up); 

    //Player_TPoseの向く方向
    //Pistol構えている時はPanelのDragで親オブジェクトPlayerAxisごとRotation
    //(MainCameraも定位置にて追従)
    if (PistolReady == true)
    {
        PlayerAxis.transform.rotation *= Quaternion.AngleAxis(
           pdScript.panelDragVector.x * 0.2f * Time.deltaTime, Vector3.up);
    }
    //Pistol構えていない時はJoystick傾けた方向にPlayer_TPoseのみRotation
    //今、説明しているのはこっち。
    else if(PistolReady == false)
    {
        if(stickDis >= 0.1f)
        {
            curLocalRotation = Quaternion.Euler(transform.localRotation.x, 
               (stickRotation + Camera.transform.localEulerAngles.y), 
                  transform.localRotation.z);
            transform.localRotation = curLocalRotation;
        }
    }

    //PlayerAxisの移動・Player_TPoseのAnimation
    //Pistol構えていない時
    if (PistolReady == false)
    { 
        //Joystickを傾けた入力がない時
        if (stickDis <= 10f)
        {
            NeutralIdleMove();
        }
        //ある程度傾けた時
        else if (stickDis > 10f && stickDis < 60f)
        {
            WalkMove();
        }
        //目一杯傾けた時
        else if ((stickDis >= 60f)
        {
            RunMove();
        }
    }
}


public void NeutralIdleMove()
{
    if(stickDis <= 10f)
    {
        //PlayerAxisは移動しない
        PlayerAxis.transform.Translate(0, 0, 0);
        animator.SetBool("NeutralIdle", true); //Animator "NeutrallIdle" をtrue
        animator.SetBool("Walk", false);
        animator.SetBool("Run", false);
        animator.SetBool("PistolIdle", false); 
        animator.SetBool("PistolWalk", false);
        animator.SetBool("PistolLeftStrafe", false);
        animator.SetBool("PistolWalkBackward", false);
        animator.SetBool("PistolRightStrafe", false);
        animator.SetBool("PistolRun", false);
        animator.SetBool("PistolRunBackward", false);
    }
}

public void WalkMove()
{
    if(stickDis > 10f && stickDis < 60f)
    {
        //Player_TPoseが向いている方向に移動
        //ポイントはthis.transform.forward(Player_TPoseのZ軸の正方向)
        PlayerAxis.transform.position += this.transform.forward * stickDis 
           * 0.06f * translateForward * Time.deltaTime;
        animator.SetBool("NeutralIdle", false);
        animator.SetBool("Walk", true); //Animator "Walk" をtrue
        animator.SetBool("Run", false);
        animator.SetBool("PistolIdle", false);
        animator.SetBool("PistolWalk", false);
        animator.SetBool("PistolLeftStrafe", false);
        animator.SetBool("PistolWalkBackward", false);
        animator.SetBool("PistolRightStrafe", false);
        animator.SetBool("PistolRun", false);
        animator.SetBool("PistolRunBackward", false);
    }   
}

public void RunMove()
{
    if (stickDis >= 60f)
    {
        //Player_TPoseが向いている方向に移動
        //ポイントはthis.transform.forward(Player_TPoseのZ軸の正方向)
        PlayerAxis.transform.position += this.transform.forward 
           * stickDis * 0.06f * translateForward * Time.deltaTime;
        animator.SetBool("NeutralIdle", false);
        animator.SetBool("Walk", false);
        animator.SetBool("Run", true); //Animator "Run" をtrue
        animator.SetBool("PistolIdle", false);
        animator.SetBool("PistolWalk", false);
        animator.SetBool("PistolLeftStrafe", false);
        animator.SetBool("PistolWalkBackward", false);
        animator.SetBool("PistolRightStrafe", false);
        animator.SetBool("PistolRun", false);
        animator.SetBool("PistolRunBackward", false);
    }      
}





❷画面上半分のPanel(UI)のドラッグ:
   プレイヤー本体を基点(厳密にはCardinalPointOfCameraを基点)にカメラの衛星回転。
   (プレイヤー方向を常に向いて、プレイヤーから常に一定の距離を保ちながら移動。)

PanelDrag.cs
//uGUIのPanelにアタッチされたスクリプトです。
private Vector3 initialPos;
private Vector3 currentPos;
public Vector3 panelDragVector;

//ドラッグ開始位置の取得
public void OnBeginDrag(PointerEventData pointerEventData)
{
    initialPos = pointerEventData.position;
    cameraDrag = true;
}

//ドラッグした 距離と長さ = ベクトル の取得
public void OnDrag(PointerEventData pointerEventData)
{
    currentPos = pointerEventData.position;
    panelDragVector = currentPos - initialPos;
}

//ドラッグ終了したのでベクトルを0にする
public void OnEndDrag(PointerEventData pointerEventData)
{
    panelDragVector = new Vector3(0, 0, 0);
    cameraDrag = false;
}
CameraPos.cs
//MainCameranにアタッチされたスクリプトです。
float cameralocalEulerAnglesX_DownMax = 300f;
float cameralocalEulerAnglesX_UpMax = 60f;

void Update()
{
    //MainCameraはプレイヤーを基点(厳密にはCardinalPointOfCameraを基点)に、
    //RotateAround()とLookAt()をするので、上下の動きの下限と上限を設定
    //これは下限
    if (transform.localEulerAngles.x <= 300f && transform.localEulerAngles.x > 180f)
    {
        transform.localEulerAngles = new Vector3(cameralocalEulerAnglesX_DownMax, 
           transform.localEulerAngles.y, transform.localEulerAngles.z);
        if (pdScript.panelDragVector.y > 0)
        {
            pdScript.panelDragVector.y = 0f;
        }
    }
    //こっちが上限
    else if (transform.localEulerAngles.x >= 60f && transform.localEulerAngles.x < 180f)
    {
        transform.localEulerAngles = new Vector3(cameralocalEulerAnglesX_UpMax, 
           transform.localEulerAngles.y, transform.localEulerAngles.z);
        if (pdScript.panelDragVector.y < 0)
        {
            pdScript.panelDragVector.y = 0f;
        }
    }

   //ピストルを構えていない時 && Panelをドラッグしている時
   //MainCameraの移動
   if(prsSlider.value == 0 && pdScript.cameraDrag)
   {
       //RotateAround関数の第一引数で設定した中心点・第二引数で軸を設定した時に、
       //中心点から見てその軸の負の方向にMainCameraがある時、
       //同入力・逆回転になるので、入力にマイナスをかける
       if (transform.localPosition.z < 0f && transform.localPosition.x < 0f)
       {   
           //上下のPanelドラッグによる、カメラのYZ平面の移動
           transform.RotateAround(CardinalPointOfCamera.transform.position,
              CardinalPointOfCamera.transform.right, 
              pdScript.panelDragVector.y * -0.2f * Time.deltaTime); 
           //上下のPanelドラッグによる、カメラのXY平面の移動
           transform.RotateAround(CardinalPointOfCamera.transform.position, 
              CardinalPointOfCamera.transform.forward, 
              pdScript.panelDragVector.y * 0.2f * Time.deltaTime); 
       }
       else if (transform.localPosition.z >= 0f && transform.localPosition.x < 0f)
       {
           //上下のPanelドラッグによる、カメラのYZ平面の移動
           transform.RotateAround(CardinalPointOfCamera.transform.position, 
              CardinalPointOfCamera.transform.right, 
              pdScript.panelDragVector.y * 0.2f * Time.deltaTime); 
           //上下のPanelドラッグによる、カメラのXY平面の移動
           transform.RotateAround(CardinalPointOfCamera.transform.position, 
              CardinalPointOfCamera.transform.forward, 
              pdScript.panelDragVector.y * 0.2f * Time.deltaTime); 
       }
       else if (transform.localPosition.z < 0f && transform.localPosition.x >= 0f)
       {  
           //上下のPanelドラッグによる、カメラのYZ平面の移動 
           transform.RotateAround(CardinalPointOfCamera.transform.position, 
              CardinalPointOfCamera.transform.right, 
              pdScript.panelDragVector.y * -0.2f * Time.deltaTime); 
           //上下のPanelドラッグによる、カメラのXY平面の移動
           transform.RotateAround(CardinalPointOfCamera.transform.position, 
              CardinalPointOfCamera.transform.forward, 
              pdScript.panelDragVector.y * -0.2f * Time.deltaTime); 
       }
       else if (transform.localPosition.z >= 0f && transform.localPosition.x >= 0f)
       {   
           //上下のPanelドラッグによる、カメラのYZ平面の移動
           transform.RotateAround(CardinalPointOfCamera.transform.position, 
              CardinalPointOfCamera.transform.right, 
              pdScript.panelDragVector.y * 0.2f * Time.deltaTime); 
           //上下のPanelドラッグによる、カメラのXY平面の移動
           transform.RotateAround(CardinalPointOfCamera.transform.position, 
              CardinalPointOfCamera.transform.forward, 
              pdScript.panelDragVector.y * -0.2f * Time.deltaTime); 
       }

       //CameraのRotationは常にPlayer方向を向く
       transform.LookAt(CardinalPointOfCamera.transform.position);
       //左右のPanelドラッグによる、カメラのZX平面の移動
       transform.RotateAround(CardinalPointOfCamera.transform.position, 
          CardinalPointOfCamera.transform.up, 
          pdScript.panelDragVector.x * 0.4f * Time.deltaTime); 
    }  
}

b. ピストル状態

実際の動きはこんな感じ。
動画:「初めてのオリジナルTPSゲーム(ピストル状態①)」
動画:「初めてのオリジナルTPSゲーム(ピストル状態②)」

こちらは、a. アイドル状態とは違い、プレイヤー本体の動きとカメラの動きが常に連動しています。
ポイントは2つで、
❶左右のプレイヤー本体の回転に対して、カメラが常に固定されている。
❷プレイヤー本体が上下に体を向ける動きに合わせて、カメラも移動する。

です。


❶左右のプレイヤー本体の回転に対して、カメラが常に固定されている。
こちらは、左右の回転の際に、両オブジェクト(プレイヤー本体とカメラ)の親オブジェクトであるPlayerAxisごと回転させています。
また。a.アイドル状態とは違い、ジョイスティックを後ろに傾けた際に、プレイヤー本体(Player_TPose)を振り向かせるのではなく、Animationでバック走させています。

PlayerMove.cs
void Update()
{
     //JoystickとPlayerの動きを連動させるための変数を用意
    stickCurrentPos = Stick.transform.localPosition;
    stickDis = (stickCurrentPos - stickInitialPos).magnitude;
    //uGUI>Joystick>Stickの現在座標から弧度(ラジアン)を算出
    stickRadian = Mathf.Atan2(stickCurrentPos.y, stickCurrentPos.x);
    //孤度(ラジアン)を角度(θ)に変換
    stickAngle = stickRadian * Mathf.Rad2Deg; 
    //(450f - stickAngle)は、uGUI>Joystick>Stick と Player の動きを直感的に操作するための補正
    //uGUI>Joystick>StickのθはX軸から反時計回り(XY平面)・PlayerのθはZ軸から時計回り(ZX平面)
    stickRotation = Quaternion.AngleAxis((450f - stickAngle), Vector3.up); 

    //Player_TPoseの向く方向
    //Pistol構えている時はPanelのDragで親オブジェクトPlayerAxisごとRotation
    //MainCameraも定位置にて追従
    //今、説明しているのはこっち。
    if (PistolReady == true)
    {
        PlayerAxis.transform.rotation *= Quaternion.AngleAxis(
           pdScript.panelDragVector.x * 0.2f * Time.deltaTime, Vector3.up);
    }
    //Pistol構えていない時はJoystick傾けた方向にPlayer_TPoseのみRotation
    else if(PistolReady == false)
    {
        if(stickDis >= 0.1f)
        {
            curLocalRotation = Quaternion.Euler(transform.localRotation.x, 
               (stickRotation + Camera.transform.localEulerAngles.y), 
                  transform.localRotation.z);
            transform.localRotation = curLocalRotation;
        }
    }

    //PlayerAxisの移動・Player_TPoseのAnimation
    //Pistol構えていない時
    if (PistolReady == true)
    { 
        //Joystickを傾けた入力がない時
        if (stickDis <= 10f)
        {
            PistolIdleMove();
        }
        //ある程度傾けた時
        else if (stickDis > 10f && stickDis < 60f)
        {
            PistolWalkMove();
        }
        //目一杯傾けた時
        else if ((stickDis >= 60f)
        {
            PistolRunMove();
        }
    }
}

public void PistolIdleMove()
{
    if (stickDis <= 10f)
    {
        PlayerAxis.transform.Translate(0, 0, 0);
        animator.SetBool("NeutralIdle", false); 
        animator.SetBool("Walk", false);
        animator.SetBool("Run", false);
        animator.SetBool("PistolIdle", true); //Animator "PistolIdle" をtrue
        animator.SetBool("PistolWalk", false);
        animator.SetBool("PistolLeftStrafe", false);
        animator.SetBool("PistolWalkBackward", false);
        animator.SetBool("PistolRightStrafe", false);
        animator.SetBool("PistolRun", false);
        animator.SetBool("PistolRunBackward", false);
    }
}

//ジョイスティックを傾けた方向で、プレイヤー本体(Player_TPose) のAnimationを変更
public void PistolWalkMove()
{
    if (stickDis > 10f && stickDis < 60f)
    {
        PlayerAxis.transform.Translate(new Vector3(
           translateX, Stick.transform.localPosition.z, translateZ) 
              / stickDis * 2.5f * Time.deltaTime);

        if (stickAngle >= 45f && stickAngle < 135f) //Joystickを前方向に傾けているとき
        {
            animator.SetBool("NeutralIdle", false);
            animator.SetBool("Walk", false); 
            animator.SetBool("Run", false);
            animator.SetBool("PistolIdle", false);
            animator.SetBool("PistolWalk", true); //Animator "PistolWalk" をtrue
            animator.SetBool("PistolLeftStrafe", false);
            animator.SetBool("PistolWalkBackward", false);
            animator.SetBool("PistolRightStrafe", false);
            animator.SetBool("PistolRun", false);
            animator.SetBool("PistolRunBackward", false);
        }
        else if ((stickAngle >= 135f && stickAngle <= 180f) || 
           (stickAngle < -135f && stickAngle > -180f)) //Joystickを左方向に傾けているとき
        {
            animator.SetBool("NeutralIdle", false);
            animator.SetBool("Walk", false); 
            animator.SetBool("Run", false);
            animator.SetBool("PistolIdle", false);
            animator.SetBool("PistolWalk", false); 
            animator.SetBool("PistolLeftStrafe", true);//Animator "PistolLeftStrafe" をtrue
            animator.SetBool("PistolWalkBackward", false);
            animator.SetBool("PistolRightStrafe", false);
            animator.SetBool("PistolRun", false);
            animator.SetBool("PistolRunBackward", false);
        }
        else if (stickAngle >= -135f && stickAngle < -45f) //Joystickを後方向に傾けているとき
        {
            animator.SetBool("NeutralIdle", false);
            animator.SetBool("Walk", false); 
            animator.SetBool("Run", false);
            animator.SetBool("PistolIdle", false);
            animator.SetBool("PistolWalk", false); 
            animator.SetBool("PistolLeftStrafe", false);
            animator.SetBool("PistolWalkBackward", true);//Animator "PistolWalkBackward" をtrue
            animator.SetBool("PistolRightStrafe", false);
            animator.SetBool("PistolRun", false);
            animator.SetBool("PistolRunBackward", false);
        }
        else if (stickAngle >= -45f && stickAngle < 45f) //Joystickを右方向に傾けているとき
        {
            animator.SetBool("NeutralIdle", false);
            animator.SetBool("Walk", false); 
            animator.SetBool("Run", false);
            animator.SetBool("PistolIdle", false);
            animator.SetBool("PistolWalk", false); 
            animator.SetBool("PistolLeftStrafe", false);
            animator.SetBool("PistolWalkBackward", false); 
            animator.SetBool("PistolRightStrafe", true);//Animator "PistolRightStrafe" をtrue
            animator.SetBool("PistolRun", false);
            animator.SetBool("PistolRunBackward", false);
        }
    }
}


public void PistolWalkMove()
{
  //PistolWalkMove()と同じ実装なので説明割愛。
}



❷プレイヤー本体が上下に体を向ける動きに合わせて、カメラも移動する。
こちらは、Panel(uGUI)の上下ドラッグに合わせて、プレイヤー本体のボーンの一つであるSpine(腰のあたり)の角度カメラの位置・角度を調整しています。

PlayerMove.cs
//Player_TPoseにアタッチされたスクリプトです。

//CameraのlocalRotationとSpineのRotationを、Pistol時に連動
private void OnAnimatorIK(int layerIndex)
{
    //ピストルを構えている時
    if (prsSlider.value == 1)
    {
        if (Camera.transform.localEulerAngles.x >= 0f 
           && Camera.transform.localEulerAngles.x <= 180f)
        {
            //カメラの回転に連動してSpineも回転
            //微調整でかけたり引いたりしています
            //ここの微調整めちゃ重要なので、各自の環境に合わせて調整してみてください
            spineRotX = Camera.transform.localEulerAngles.x * 0.85f - 8.7f;
            spineRotY = Camera.transform.localEulerAngles.x * 0.15f - 6f;
            spineRotZ = Camera.transform.localEulerAngles.x * 0.61f - 6.13f;
        }
        else if (Camera.transform.localEulerAngles.x > 180f 
           && Camera.transform.localEulerAngles.x <= 360f)
        {
            //同様に微調整
            spineRotX = (Camera.transform.localEulerAngles.x - 360f) * 0.75f - 8.7f;
            spineRotY = (Camera.transform.localEulerAngles.x -360f) * -0.5f - 6f;
            spineRotZ = (Camera.transform.localEulerAngles.x - 360f) * 0.54f - 6.13f;
        }
        animator.SetBoneLocalRotation(
           HumanBodyBones.Spine, Quaternion.Euler(spineRotX, spineRotY, spineRotZ));

        if (pdScript.cameraDrag)
        {
            animator.SetBoneLocalRotation(
               HumanBodyBones.Spine, Quaternion.Euler(spineRotX, spineRotY, spineRotZ));
        }
    }
    else if (prsSlider.value == 0)
    {
        spineRotX = -3.63f;
        spineRotY = -6.35f;
        spineRotZ = -2.6f;
    }
}
CameraPos.cs
//MainCameraにアタッチされたスクリプトです。
void Update()
{
    //Pistolを構えていて、PanelをDragしている時
    if (prsSlider.value == 1 && pdScript.cameraDrag)
    {
        //上下のPanelドラッグによる、カメラのXY平面の移動
        //今回MainCameraは上下の動きのみ
        transform.RotateAround(CardinalPointOfCamera.transform.position, 
           CardinalPointOfCamera.transform.right, 
           pdScript.panelDragVector.y * -0.3f * Time.deltaTime); 
        //CameraのRotationは常にPlayer方向を向く
        transform.LookAt(CardinalPointOfCamera.transform.position);
    }
}

4. おわりに

自分の備忘録も兼ねて、実装方法の解説をしてきましたが、カメラ編から含めてこれで完了です。
カメラ編でも最後に書きましたが、私も初めてのオリジナルゲームで最適な実装方法をできているとは思っていません。
ただ、誰かのヒントに少しでもなれば幸いです。(私も色んな方の色んな記事を読みまくってなんとか作り上げることができたので、、、)
最後まで読んでいただきありがとうございました。

下記に連絡先を載せておきます。Resumeにポートフォリオを載せているので、是非みてください。
Twitter: なんじょー@AR勉強中(@12reoer21)
GitHub : 12oreo21
Resume : なんじょー@AR勉強中

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

初めてのオリジナルTPSゲーム作成 ~カメラ編~

アジェンダ

1.はじめに
2.ヒエラルキーの確認
3.コードの確認
 a.アイドル状態 ⇄ ピストル状態
 b.壁際
 c.ピストル状態 × 壁際
4. おわりに

1. はじめに

初めてのオリジナルゲームでシューティング系のTPSゲームを作成しました。プレイ動画はこんな感じ。
動画:「初めてのオリジナルTPSゲーム(プレイ動画)」

その実装方法を、2.ヒエラルキーの確認、3.コードの確認に分けて解説していきます。
誰かのヒントになると嬉しいです。至らぬ点・不明点があればご指摘いただけると幸いです。

2.ヒエラルキーの確認

プレイヤーのヒエラルキービューはこんな感じ。
2020-05-14 16.41のイメージ.jpeg
-PlayerAxis:
 プレイヤー本体(Player_TPose。3Dモデル)とカメラ(MainCamera)の親オブジェクト。
 プレイヤー本体の動きとカメラの動きを切り分けるため、両オブジェクトの親を設けた。
-Player_TPose:
 プレイヤー本体(3Dモデル)。
-MainCamera:
 カメラ。
-CardinalPointOfCamera:
 カメラが向く方向を設定するための基点。
-rayPosition:
 プレイヤーの接地判定を行うため、真下(ローカル座標系のY軸のマイナス方向)にrayを飛ばしている。
 今回は特に触れないので無視してOK。

今回の記事では、
① PlayerAxisという親オブジェクトが、Player_TPoseとMainCameraの動きを切り分けるために設けられている。
②Player_TPoseがプレイヤー本体 = 3Dモデル。
③MainCameraがカメラ。
④CardinalPointOfCameraは、MainCameraが向く方向の基点。
ということを覚えておいてください。

※①については続編記事の「初めてのオリジナルTPSゲーム作成 ~カメラとプレイヤーの連動編~」で詳しく解説。
(一言解説しておくと、Player_TPoseの向きが変更された時にMainCameraの向きも合わせて変更されたら困るからです。)

※④について具体的にはMainCameraにアタッチされたScriptで下記のように実装しているからです。
MainCamera.transform.LookAt(CardinalPointOfCamera.transform.position);


また、子オブジェクトそれぞれのTransform(親オブジェクトPlayerAxisを基準としたローカル座標系)の初期値を載せておきます。
-Player_TPose
2020-05-14 17.22のイメージ.jpeg

-MainCamera
2020-05-14 17.23のイメージ.jpeg

-CardinalPointOfCamera
2020-05-14 17.24のイメージ.jpeg

3.コードの確認

今回はCameraの動き単体にスポットを当てて、そのうちの3つに分けて解説していきます。
念のため、アジェンダを再掲しておきます。

 a.ピストル状態 ⇄ アイドル状態
 b.壁際
 c.ピストル状態 × 壁際

また、これから説明するコードが書かれたScriptは、MainCameraにアタッチされています。
では、早速それぞれ見ていきましょう。


a.アイドル状態 ⇄ ピストル状態

実際の動きはこんな感じ。
動画:「初めてのオリジナルTPSゲーム(ピストル ⇄ アイドル)」
画面右下のSliderを上にスライドするとピストルを構えた「ピストル状態」、下にするとピストルを構えない「アイドル状態」になります。

ここでは、アイドル状態 → ピストル状態について説明します。逆も実装方法が全く一緒なので割愛します。
ポイントは2つです。
❶画面中央の赤丸(MainCameraが映し出す映像の中央)が向いている方向に、プレイヤーの体を向ける。
 (具体的には、PlayerAxisをその方向に向けて、子オブジェクトPlayer_TposeのRotationを(0,0,0)にする)
❷MainCameraとCardinalPointOfCameraをピストル状態の位置に移動させる。

ソースコードの中身はこんな感じ。

CameraPos.cs
void Update()
{
    if (prsSlider.value == 1 && cbpScript.Crouch == false && cameraZoomin == true)
    {
         if (doOnceCurPlayerRotation)
         {
             cameraDoubleTapReset = false;
             sliderFillImage.raycastTarget = false;
             psbImage.raycastTarget = false;
             pdImage.raycastTarget = false;
             cbImage.raycastTarget = false;
             independentCameraTrans = true;
             dirWhenTrans = 
                (CardinalPointOfCamera.transform.position - transform.position).normalized;
             raycastWhenTrans = Physics.Raycast(
               transform.position, dirWhenTrans, out hitInfoWhenTrans, disWhenTrans, layerMask
               );
             if (raycastWhenTrans)
             {
                 transform.parent = null;
                 PlayerAxis.transform.LookAt(
                    new Vector3(hitInfoWhenTrans.point.x, PlayerAxis.transform.position.y,
                       hitInfoWhenTrans.point.z), Vector3.up);
                 transform.parent = PlayerAxis.transform;
                 destination = CardinalPosPistol + ((CardinalPosPistol - 
                    PlayerAxis.transform.InverseTransformPoint(
                       hitInfoWhenTrans.point)).normalized 
                          * initialDisFromCardinalToCameraWhenPistol);
             }
             else if (!raycastWhenTrans)
             {
                 transform.parent = null;
                 Vector3 endPoint = transform.position + (dirWhenTrans * disWhenTrans);
                 PlayerAxis.transform.LookAt(
                    new Vector3(endPoint.x, PlayerAxis.transform.position.y, endPoint.z),
                       Vector3.up);
                 transform.parent = PlayerAxis.transform;
                 destination = CardinalPosPistol + ((CardinalPosPistol - 
                    PlayerAxis.transform.InverseTransformPoint(endPoint)).normalized 
                       * initialDisFromCardinalToCameraWhenPistol);
             }
             Player.transform.localRotation = Quaternion.Euler(0f, 0f, 0f);
             doOnceCurPlayerRotation = false;
         }
         else if (!doOnceCurPlayerRotation)
         {
             Vector3 curLocalPos = transform.localPosition;
             curLocalPos = Vector3.Lerp(
                curLocalPos, destination, Time.deltaTime * cameraMoveSpeed);
             transform.localPosition = curLocalPos;
             Vector3 curLocalPosCardinal = CardinalPointOfCamera.transform.localPosition;
             Vector3 destinationCardinal = CardinalPosPistol;
             curLocalPosCardinal = Vector3.Lerp(
                curLocalPosCardinal, destinationCardinal, Time.deltaTime * cameraMoveSpeed);
             CardinalPointOfCamera.transform.localPosition = curLocalPosCardinal;

             transform.LookAt(CardinalPointOfCamera.transform.position);

             //Camera位置を目標位置まできちんと移動させる
             if ((transform.localPosition.y - destination.y) <= 0.001f &&
                   (transform.localPosition.y - destination.y) >= -0.001f)
             {
                 transform.localPosition = destination;
                 CardinalPointOfCamera.transform.localPosition = destinationCardinal;
                 transform.LookAt(CardinalPointOfCamera.transform.position);
                 cameraDoubleTapReset = true;
                 cameraZoomin = false;
                 cameraZoomout = true;
                 cameraCrouchZoomin = true;
                 cameraCrouchZoomout = true;
                 doOnceCurPlayerRotation = true;
                 sliderFillImage.raycastTarget = true;
                 psbImage.raycastTarget = true;
                 pdImage.raycastTarget = true;
                 cbImage.raycastTarget = true;
                 independentCameraTrans = false;
             }
         }
     }
}

上記コードはコードをそのまま貼り付けたので、今回説明する部分以外のところが含まれている(この実装部分のカメラの動きと、他の実装部分でのカメラの動きが同時に実行しない用の変数など)ので、
今回説明する部分だけに絞るとこんな感じ。

CameraPos.cs
void Update()
{
    //ピストル状態になる時(画面右下のSliderを上にスライドさせた時)
    if (prsSlider.value == 1)
    {
         //このif内は一度だけ実行される
         //MainCamera・CardinalPointOfCameraの移動開始前に、移動先情報を確定、
         //また、PlayerAxisとPlayer_TPoseの回転(向く方向)を完了させておく。
         if (doOnceCurPlayerRotation)
         {
             //MainCamera → CardinalPointOfCamera方向のベクトル
             //MainCameraは常にCardinalPointOfCameraを向くようにしているので、画面中央からの法線ベクトル
             dirWhenTrans = 
                (CardinalPointOfCamera.transform.position - transform.position).normalized;
             //画面中央からrayを飛ばしている
             //rayの衝突地点にMainCameraが向く
             raycastWhenTrans = Physics.Raycast(
               transform.position, dirWhenTrans, out hitInfoWhenTrans, disWhenTrans, layerMask
               );
             //rayが衝突した時
             if (raycastWhenTrans)
             {
                 //PlayerAxisを回転させる前にMainCameraと一旦親子関係を外す
                 //MainCameraも回転されたらカメラ映像が高速で移動して困るため
                 transform.parent = null;
                 //PlayerAxis回転
                 PlayerAxis.transform.LookAt(
                    new Vector3(hitInfoWhenTrans.point.x, PlayerAxis.transform.position.y,
                       hitInfoWhenTrans.point.z), Vector3.up);
                 //回転終了後、親子関係を戻す
                 transform.parent = PlayerAxis.transform;
                 //MainCameraの移動先を確定
                 //CardinalPosPistolはピストル状態時のCardinalPointOfCameraの位置・移動先
                 //rayの衝突地点 → CardinalPosPistol の延長線上がMainCameraの移動先 
                 destination = CardinalPosPistol + ((CardinalPosPistol - 
                    PlayerAxis.transform.InverseTransformPoint(
                       hitInfoWhenTrans.point)).normalized 
                          * initialDisFromCardinalToCameraWhenPistol);
             }
             //rayが衝突しなかった時
             //rayの衝突地点が、rayの最終地点になっているだけで、実装方法は上と全く一緒。説明割愛。
             else if (!raycastWhenTrans)
             {
                 transform.parent = null;
                 Vector3 endPoint = transform.position + (dirWhenTrans * disWhenTrans);
                 PlayerAxis.transform.LookAt(
                    new Vector3(endPoint.x, PlayerAxis.transform.position.y, endPoint.z),
                       Vector3.up);
                 transform.parent = PlayerAxis.transform;
                 destination = CardinalPosPistol + ((CardinalPosPistol - 
                    PlayerAxis.transform.InverseTransformPoint(endPoint)).normalized 
                       * initialDisFromCardinalToCameraWhenPistol);
             }
             Player.transform.localRotation = Quaternion.Euler(0f, 0f, 0f);
             doOnceCurPlayerRotation = false;
         }
         //前準備が完了したら、MainCameraとCardinalPointOfCameraの移動開始
         else if (!doOnceCurPlayerRotation)
         {
             //MainCameraの現ローカル座標(PlayerAxis基準の相対的位置)
             Vector3 curLocalPos = transform.localPosition;
             //Lerp関数でMainCameraを滑らかに移動
             curLocalPos = Vector3.Lerp(
                curLocalPos, destination, Time.deltaTime * cameraMoveSpeed);
             //MainCameraの現ローカル座標の更新
             transform.localPosition = curLocalPos;
             //CardinalPointOfCameraもMianCameraと全く同じ実装方法
             Vector3 curLocalPosCardinal = CardinalPointOfCamera.transform.localPosition;
             Vector3 destinationCardinal = CardinalPosPistol;
             curLocalPosCardinal = Vector3.Lerp(
                curLocalPosCardinal, destinationCardinal, Time.deltaTime * cameraMoveSpeed);
             CardinalPointOfCamera.transform.localPosition = curLocalPosCardinal;

             //MainCameraは常にCardinalPointOfCameraを向く
             transform.LookAt(CardinalPointOfCamera.transform.position);

             //MainCameraとCardinalPointOfCameraを目標位置まできちんと移動させきる
             if ((transform.localPosition.y - destination.y) <= 0.001f &&
                   (transform.localPosition.y - destination.y) >= -0.001f)
             {
                 transform.localPosition = destination;
                 CardinalPointOfCamera.transform.localPosition = destinationCardinal;
                 transform.LookAt(CardinalPointOfCamera.transform.position);
             }
         }
     }
}

b.壁際

実際の動きはこんな感じ。
動画:「初めてのオリジナルTPSゲーム(壁際)」

ポイントは2つです。
①カメラが壁に遮られないように、プレイヤーから見て壁の手前に移動している
②壁際から離れた時、Cameraを元の軌道上(元の位置)に戻す

説明する部分のみのコードはこんな感じ。
このコード内に「画面をドラッグするとMainCameraがCardinalPointOfCameraを基点にRotateAround関数で移動する」部分が実装されていましたが、長すぎるので省略しました。

CameraPos.cs
void Update()
{
    //Pistolを構えていない時
    if (prsSlider.value == 0)
    {   
        //CardinalPointOfCamera → MainCamera方向のベクトル
        directionFromCardinalToCamera = 
           (transform.position - CardinalPointOfCamera.transform.position).normalized;
        //MainCameraの壁接触判定用のray
        //CardinalPointOfCamera(MainCameraが常に向いている点)からMainCameraまでrayを飛ばす
        viewobstacleRaycast = Physics.Raycast(CardinalPointOfCamera.transform.position, 
           directionFromCardinalToCamera, out viewobstacleHitInfo, 
              intialDisFromCardinalToCamera, layerMask);
        //カメラが壁際にある時
        if (viewobstacleRaycast)
        {   
            //ray衝突がプレイヤー自身じゃないことを念のためチェック
            //(layermaskでプレイヤーに衝突しないよう設定すればいいのでぶっちゃけ無駄です。。。)
            if (!viewobstacleHitInfo.transform.CompareTag("Player"))
            {
                //壁際になった際に、壁際から離れた時に一度だけ実行するためのbool値をtrueに設定
                //これで壁際から離れた際に一度だけ実行する準備完了
                doOnceReturnToOriginalPos = true;
                //MainCameraの現ローカル座標
                Vector3 curLocalPos = transform.localPosition;
                //MainCameraの移動先(最終地点)をrayの衝突地点に設定
                Vector3 destination = 
                   PlayerAxis.transform.InverseTransformPoint(viewobstacleHitInfo.point);
                //Lerp関数でMainCameraを滑らかに移動
                curLocalPos = Vector3.Lerp(
                   curLocalPos, destination, Time.deltaTime * cameraMoveSpeed);
                transform.localPosition = curLocalPos;

                //CardinalPointOfCameraもLerp関数で滑らかに移動
                //CardinalPosWallは事前に設定している、壁際にMainCameraがある時用の位置
                Vector3 curLocalPosCardinal = CardinalPointOfCamera.transform.localPosition;
                Vector3 destinationCardianl = CardinalPosWall;
                curLocalPosCardinal = Vector3.Lerp(
                  curLocalPosCardinal, destinationCardianl, Time.deltaTime * cameraMoveSpeed);
                CardinalPointOfCamera.transform.localPosition = curLocalPosCardinal;

                //MainCameraは常にCardinalPointOfCameraの方向を向く
                transform.LookAt(CardinalPointOfCamera.transform.position); 
            }
        }
        //カメラが壁際にない時
        else if (!viewobstacleRaycast || 
                  (viewobstacleRaycast && viewobstacleHitInfo.transform.CompareTag("Player")))
        {       
            //「壁際にある」→「壁際にない」に条件変化した際に一度だけ実行
            if (doOnceReturnToOriginalPos)
            {   
                //CardinalPointOfCameraを元々の位置に戻す
                Vector3 curLocalPosCardinal = CardinalPointOfCamera.transform.localPosition;
                Vector3 destinationCardinal = CardinalPosOriginal;
                curLocalPosCardinal = Vector3.Lerp(
                  curLocalPosCardinal, destinationCardinal, Time.deltaTime * cameraMoveSpeed);
                CardinalPointOfCamera.transform.localPosition = curLocalPosCardinal;

                //MainCameraを元々の軌道上(位置)に戻す
                //intialDisFromCardinalToCameraで、CardinalPointOfCameraからの元々の距離に戻している
                Vector3 curLocalPosCamera = transform.localPosition;
                Vector3 destinationCamera = destinationCardinal + ((transform.localPosition -
                   CardinalPointOfCamera.transform.localPosition).normalized 
                      * intialDisFromCardinalToCamera);
                curLocalPosCamera = Vector3.Lerp(
                   curLocalPosCamera, destinationCamera, Time.deltaTime * cameraMoveSpeed);
                transform.localPosition = curLocalPosCamera;

                //MainCameraは常にCardinalPointOfCameraの方向を向く
                transform.LookAt(CardinalPointOfCamera.transform.position);

                //MainCameraとCardinalPointOfCameraを最終地点まで移動させきる
                if ((curLocalPosCardinal.y - destinationCardinal.y) <= 0.001f &&
                      (curLocalPosCardinal.y - destinationCardinal.y) >= -0.001f)
                {   
                    CardinalPointOfCamera.transform.localPosition = destinationCardinal;
                    transform.localPosition = destinationCamera;
                    transform.LookAt(CardinalPointOfCamera.transform.position);

                    //一度だけ実行するためfalseにする
                    doOnceReturnToOriginalPos = false;
                }
            }
            else if(!doOnceReturnToOriginalPos)
            {
                //ここに壁際にない時の、通常時の、MainCameraの動きを実装。
                //長くなるので割愛。
            }
        }
    }
}

c.ピストル状態 × 壁際

最後に、「ピストル状態」かつ、「壁際」での実装部分を解説します。
実際の動きはこんな感じ。
動画:「初めてのオリジナルTPSゲーム(ピストル状態①)」

ポイントは1つで、
壁際にあるときでも、MainCameraが向く方向と弾が飛んでいく方向の整合性が取れている
(ピストルを構えたプレイヤーとMainCameraの相対的位置が変化しない)

です。

コードはこんな感じ。(余分なところは省略しています)
b.壁際で説明したコードとほぼ同じですが、違う点は、こちらではCardinalPointOfCameraを移動させない点です。
ピストル状態の時は、銃口とMainCameraの向く方向性が、壁際にあろうとも、常に同じ方向を向いていてほしいので、MainCameraが向く方向の基点になるCardinalPointOfCameraは動かしません。
その他は、b.壁際と同じ実装なので説明しません。

Camera.Pos.cs
void Update()
{
    //Pistolを構えている時
    if (prsSlider.value == 1 && !independentCameraTrans)
    {
        Vector3 directionWhenPistol = 
           (transform.position - CardinalPointOfCamera.transform.position).normalized;
        float disWhenPistol = (originalLocalCameraPosWhenPistol - 
           CardinalPointOfCamera.transform.localPosition).magnitude;
        viewobstacleRaycastWhenPistol = Physics.Raycast(
           CardinalPointOfCamera.transform.position, directionWhenPistol, 
              out viewobstacleHitInfoWhenPistol, disWhenPistol, layerMask);

        //MainCameraが壁際にある時
        if (viewobstacleRaycastWhenPistol)
        {
            if (!viewobstacleHitInfoWhenPistol.transform.CompareTag("Player"))
            {
                doOnceReturnToOriginalPos = true;
                Vector3 curLocalPos = transform.localPosition;
                Vector3 destination = PlayerAxis.transform.InverseTransformPoint(
                   viewobstacleHitInfoWhenPistol.point);
                curLocalPos = Vector3.Lerp(curLocalPos, destination, 
                   Time.deltaTime * cameraMoveSpeed);
                transform.localPosition = curLocalPos;
                transform.LookAt(CardinalPointOfCamera.transform.position);
            }
        }
        //MainCameraが壁際にない時
        else if (!viewobstacleRaycastWhenPistol || (viewobstacleRaycastWhenPistol && 
                   viewobstacleHitInfoWhenPistol.transform.CompareTag("Player")))
        {
            //「壁際にある」→「壁際にない」に条件変化した際に一度だけ実行
            if (doOnceReturnToOriginalPos)
            {
                Vector3 curLocalPosCamera = transform.localPosition;
                Vector3 destinationCamera = CardinalPointOfCamera.transform.localPosition + 
                   ((transform.localPosition - 
                      CardinalPointOfCamera.transform.localPosition).normalized * 
                         initialDisFromCardinalToCameraWhenPistol);
                curLocalPosCamera = Vector3.Lerp(
                   curLocalPosCamera, destinationCamera, Time.deltaTime * cameraMoveSpeed);
                transform.localPosition = curLocalPosCamera;

                transform.LookAt(CardinalPointOfCamera.transform.position);

                //MainCameraを最終地点まで移動させきる
                if ((curLocalPosCamera.z - destinationCamera.z) <= 0.001f && 
                   (curLocalPosCamera.z - destinationCamera.z) >= -0.001f)
                {
                    transform.localPosition = destinationCamera;
                    transform.LookAt(CardinalPointOfCamera.transform.position);

                    doOnceReturnToOriginalPos = false;
                }
            }
            else if(!doOnceReturnToOriginalPos)
            {
                //ここに壁際にない時の、通常時の、MainCameraの動きを実装。
                //長くなるので割愛。
            }
        }
    }
}

4. おわりに

今回は、自分の備忘録も兼ねて、実装方法の解説をしてみました。
私も初めてのオリジナルゲームで最適な実装方法をできているとは思っていません。
ただ、誰かのヒントに少しでもなれば幸いです。(私も色んな方の色んな記事を読みまくってなんとか作り上げることができたので、、、)

下記に連絡先を載せておきます。Resumeにポートフォリオを載せているので、是非みてください。
Twitter: なんじょー@AR勉強中(@12reoer21)
GitHub : 12oreo21
Resume : なんじょー@AR勉強中

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

Re: C#(Unity)でHTTP/3通信してみる その弐 ~Windowsでquicheのサンプルを動かしてみる~

この記事は
【HTTP/3】C#でHTTP/3通信してみる その弐 ~Windowsでquicheのサンプルを動かしてみる~
を 2020/5/17 現在の状況に合わせて修正したものです。

Re: C#(Unity)でHTTP/3通信してみる その壱 では quiche の .lib/.dll を作成する方法を紹介しました。
今回はこれらを使って Windows 環境で quiche のサンプルを使って通信をしてみようと思います。

更新履歴

  • 2020/5/20
    • QuicheWindowsSample : メモリ破壊するバグを修正(quiche_h3_event_free の呼び出しタイミング)
    • 上記に合わせて記事も微修正

古い記事 C#でHTTP/3通信してみる その弐 からの変更点まとめ

  • サンプルのコード整理
    • 1350 byte より大きなデータをダウンロードできない問題を修正
    • quiche 0.4.0 に対応
  • QUICHE_ERR_DONE に関する記載を修正(公式サンプル側からも消えたのでこちらからも削除)

Windows 版 quiche サンプル

「さっさとサンプルを動かしてみたい!」という方向けに、筆者が作成した Windows 版 quiche サンプルへのリンクを紹介しておきます。
http3sharp - QuicheWindowsSample
ビルド方法等は README.md を参照ください。

HTTP/3 サーバの準備

幸い、世の中には既に HTTP/3 に対応しているページが存在しているので、めんどくさい人はそこを使ってテストさせて貰うのが良いでしょう。

上記以外にも quicwg/base-drafts - Implementations に Public Server を設置してくれている所の紹介があります。
ただし、サーバ無しにクライアントコードを直し始めるとデバッグが辛いので、個人的には HTTP/3 サーバを自前で用意するのをお勧めします。

自前で HTTP/3 対応サーバを建てたい場合

前述した通り、デバッグの観点からすると、自前で HTTP/3 サーバを建てた方が色々と捗ります。
HTTP/3 サーバを建てる場合は、現状では以下のいずれかを選択すると楽だと思います。

  • quiche のサーバサンプルコードを Linux でビルドして使う
  • Nginx の HTTP/3 実験実装を有効にして使う (参考 : Experiment with HTTP/3 using NGINX and quiche)
  • curl の HTTP/3 実験実装をビルドして使う
  • Python 製の HTTP/3 モジュールである aioquic を使う

quiche のサーバサンプルコードを Windows でビルドするのはかなり手間が掛かるのであまりお勧めしません1
curl や quiche の Linux のサンプルコードや Nginx を使いたい場合、以下の記事で flano_yuki さんがやり方を公開してくれていますのでそれらを参考にしてみてください。

個人的には aioquic も手早く使えて良いと思うので紹介してみます。

aioquic のインストール

以下は Linux 環境に慣れていない読者の方向け導入方法です。
(Linux に慣れている方は公式のヘルプに沿えばサクッとインストールできると思います)

本記事のお題が「C# で HTTP/3 を使う」なので、 Windows 環境での導入を想定して進めてみます。
とは言え、公式ドキュメントの記述に沿ってインストールを進めた方が面倒がないので、今回は Windows 10 の仮想 Linux 環境である WSL を使います。

まず以下の記事を読んで WSL を有効化し、「Ubuntu」をインストールしてください。
※バージョンの指定がない「Ubuntu」を指定しましょう

【WSL入門】第1回 Windows 10標準Linux環境WSLを始めよう
※入れるのは WSL1 にしてください

aioquic の動作には Python 3.6 以降と OpenSSL のヘッダが必要です。
Ubuntu を起動し、まず以下のコマンドでこれらをインストールします。

$ sudo apt-get update
$ sudo apt install libssl-dev python3-dev python3-pip

ちょっと時間が掛かるのでスマホゲーでも遊びながらまったり待ちましょう。
途中で何か色々表示された時は特に考えずに yes を選択して OK です。

上記のインストールが完了したら aioquic を clone してインストールします。

$ git clone https://github.com/aiortc/aioquic.git
$ cd aioquic/
$ pip3 install -e .
$ pip3 install aiofiles asgiref httpbin starlette wsproto

完了!

aioquic サーバの起動

あとは起動するだけです。
証明書もリポジトリに同梱されているので、以下のコマンドをそのまま実行しましょう。
(-v オプションを付けると詳細なログがでるようになります)

$ python3 examples/http3_server.py --certificate tests/ssl_cert.pem --private-key tests/ssl_key.pem

※実行時にファイアウォールの設定が出る場合は許可しておきましょう

無事起動できたか、 aioquic のクライアントで確認してみます。

$ python3 examples/http3_client.py --ca-certs tests/pycacert.pem https://localhost:4433/

以下のような感じで結果が表示されれば OK です。

2020-05-17 11:51:56,945 INFO quic [82a1d100752846ab] ALPN negotiated protocol h3-27
2020-05-17 11:51:56,945 INFO client New session ticket received
2020-05-17 11:51:56,951 INFO client Received 1068 bytes in 0.0 s (1.567 Mbps)

通信結果を取得したい場合は --output-dir オプションを使います。

$ python3 examples/http3_client.py --ca-certs tests/pycacert.pem --output-dir ./ https://localhost:4433/

上記コマンドを実行すると、通信結果 index.html がカレントディレクトリに保存されます。
保存した通信結果を表示したい時は cat コマンドを使用してみてください。
以下のような内容の表示がされれば通信が成功しています。

$ cat index.html
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
        <title>aioquic</title>
        <link rel="stylesheet" href="/style.css"/>
    </head>
    <body>
        <h1>Welcome to aioquic</h1>
        <p>
            This is a test page for <a href="https://github.com/aiortc/aioquic/">aioquic</a>,
            a QUIC and HTTP/3 implementation written in Python.
        </p>

        <p>
            Congratulations, you loaded this page using HTTP/3!
        </p>

        <h2>Available endpoints</h2>
        <ul>
            <li><strong>GET /</strong> returns the homepage</li>
            <li><strong>GET /NNNNN</strong> returns NNNNN bytes of plain text</li>
            <li><strong>POST /echo</strong> returns the request data</li>
            <li>
                <strong>CONNECT /ws</strong> runs a WebSocket echo service.
                You must set the <em>:protocol</em> pseudo-header to <em>"websocket"</em>.
            </li>
            <li>There is also an <a href="/httpbin/">httpbin instance</a>.</li>
        </ul>
    </body>
</html>

通信が上手くいかないときは -v オプションで実行ログが表示されるようになる(クライアント、サーバ共に)ので、そこから原因を追いかけてみてください。

ちなみに、上記のレスポンス内にもありますが、この aioquic サーバは
 GET, GET/NNNNN, POST
に対応しているようです。
(GET/NNNNN は Z が指定 byte 分返ってきます)

注意事項と対応バージョン

サーバの準備ができたので、次は quiche の基本的な動作を理解しましょう。
今回は C 言語で実装されたサンプルを Windows に対応させますが、 C 言語実装のリファレンスは用意されていません。
幸いなことに Rust 実装のリファレンスは存在しています ので、随時読み替えながら理解を進めていくのが良さそうです。
例えば Rust 側の quiche::h3::Config 名前空間のモジュールは C 言語側では quiche_h3_config_XXXX` で定義されています。

注意事項

この後の記事内容は HTTP/3, QUIC の仕様をある程度理解している前提で進めます。
以下の仕様概要を把握していないと実装の流れが理解できない所があるのでご注意ください。

  • SCID,DCID 等のコネクション管理の流れ
  • ハンドシェイクと初期化パラメータ設定の流れ
  • バージョンネゴシエーション
  • コネクションマイグレーション
  • QPACK

これらの仕様を理解したい場合、以下のいずれかを読むのをお勧めします。

バージョン

まず、今回の記事は HTTP/3, QUIC ともに draft version 27 での動作確認となります。
利用した quiche, boringssl, aioquic のバージョンは以下の通りです。

  • quiche : 0.4.0
  • boringssl : quiche の submodule バージョンに準ずる
  • aioquic : a38756bab2cd9039e89a06a1f442f330d349eea0 (master)

サンプルの内容を確認する

quiche のサンプルは C 言語のものと Rust のものが存在します。
筆者は Rust ド素人なので、本記事では C 言語向けのサンプルを改変していきます。
quiche リポジトリでは、 C 言語向けの関連ファイルは以下のパスに配置してあります。

  • C Header
    • include/quiche.h
  • HTTP/3 向けサンプル
    • examples/http3-client.c

それでは、中を確認していきましょう。

プラットフォーム依存処理の確認

Windows 向けのビルド環境が用意されていないことから分かるように、 quiche のサンプルは当然 Linux 向けです。
そこで、まずはどの程度プラットフォーム依存があるのか確認してみます。

最初は利用しているヘッダの確認から。

#include <inttypes.h>
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <stdbool.h>
#include <unistd.h>

#include <fcntl.h>
#include <errno.h>

#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>

#include <ev.h>

予想通り Linux 系のヘッダが色々ありますね……。
型まわりはどうとでもなるとして、一番の障壁として、どうやら libev で非同期の通信処理を実装しているようです。
これをどうするかは後で考えることにします。

とりあえず libev 関連処理に目をつぶりながら実処理を見ていくと socket も自前で叩く必要があるようで、この辺りも Windows 向けに改変する必要がありそうです。

    const struct addrinfo hints = {
        .ai_family = PF_UNSPEC,
        .ai_socktype = SOCK_DGRAM,
        .ai_protocol = IPPROTO_UDP
    };

    quiche_enable_debug_logging(debug_log, NULL);

    struct addrinfo *peer;
    if (getaddrinfo(host, port, &hints, &peer) != 0) {
        perror("failed to resolve host");
        return -1;
    }

    int sock = socket(peer->ai_family, SOCK_DGRAM, 0);
    if (sock < 0) {
        perror("failed to create socket");
        return -1;
    }

    if (fcntl(sock, F_SETFL, O_NONBLOCK) != 0) {
        perror("failed to make socket non-blocking");
        return -1;
    }

    if (connect(sock, peer->ai_addr, peer->ai_addrlen) < 0) {
        perror("failed to connect socket");
        return -1;
    }

その他、SCID の作成に /dev/urandom を使っています。
これは std::random_device あたりでサクッと置き換え可能なのであまり気にしないで良いでしょう。

    uint8_t scid[LOCAL_CONN_ID_LEN];
    int rng = open("/dev/urandom", O_RDONLY);
    if (rng < 0) {
        perror("failed to open /dev/urandom");
        return -1;
    }

大きなプラットフォーム固有の処理は上記の三点程度でした。

処理の流れを見る

プラットフォーム依存の処理は把握できました。
今回はサンプルを動かすことを通して quiche の実装を把握するのが目的である為、本筋でない libev をどうにかするようながっつりとした変更はあまりしたくありません……。
とりあえず結論は保留して、 quiche の処理の流れも把握してみてから考えることにしてみます。

初期化の流れ

  1. quiche_config_new で quiche のコンフィグを作成し、quiche_config_set_xxx 等で設定を行う
  2. SCID を生成する
  3. コンフィグと SCID を引数に quiche_connect を呼び出し QUIC のコネクションを作成する

quiche はデータの送受信には socket を使用します。
quiche そのものの初期化ではありませんが、このタイミングで UDP socket を作成し、connect で host に紐づけしておく必要があります。

ハンドシェイク(1-RTT)の流れ

  1. quiche_conn_send で Initial Packet を生成する
  2. send で上記で作成した Initial Packet を送信する
  3. recv でサーバからの応答を受け取る(Initial/Handshake/1-RTT Packet)
  4. quiche_conn_recv で受信したデータを quiche 側に渡す
  5. 必要なデータを全て受信し終えるまで 3,4 を繰り返す

quiche_conn_send は呼び出すだけで引数の quiche connection のステータスに応じたデータを勝手に作成してくれます。
このタイミングは quiche_connect 後の初呼び出しとなるので Initial Packet のデータを作成してくれます。
送受信の実処理はユーザ側で実装する必要があるので、初期化時に作成した UDP socket を使って send/recv を呼び出します。
5 の判定については quiche_conn_is_established 等を使用します(詳細は後述)。

各ストリーム通信の流れ

  1. quiche_h3_config_new でストリーム用のコンフィグの設定を生成する
  2. quiche_h3_conn_new_with_transport でストリーム用のデータを作成する
  3. quiche_h3_header で HTTP ヘッダを作成する
  4. quiche_h3_send_request でストリームの生成を行う(ここで初めてストリーム ID が付与)
  5. quiche_conn_send で 4 のストリームデータを取得する
  6. send で上記のデータを送信する
  7. recv でサーバからの応答を受け取る
  8. quiche_conn_recv で受信したデータを quiche 側に渡す
  9. 必要なデータを全て受信し終えるまで繰り返す
  10. quiche_h3_conn_poll で quiche 側のイベントを検出する
  11. QUICHE_H3_EVENT_DATA が検出されたら quiche_h3_recv_body で複合化された HTTP ボディを quiche から受け取る

長いですが、やっていることは
ストリームを作成して HTTP リクエストを送信 → レスポンスを受信
しているだけです。
また、 5~9 の流れはハンドシェイク時と同じで、これは quiche の基本的な処理の流れとなるようです。

サンプルの修正方針を決める

quiche を用いた通信の大まかな流れは把握できました。
(0-RTT 時やコネクションマイグレーション時等の不明な点はとりあえず現時点では忘れます)
これをもとにサンプルをどう直すか改めて考えてみます。

前述したように、最終的なゴールはサンプルを動かすのではなく、サンプル実装を通して quiche の処理の流れや API を把握することです。
処理の流れを見た感じ、イベント駆動でなく雑にポーリングするような実装でも問題はなさそうです。
ですので、思い切ってイベントベースでの動作は諦めて、以下のお手軽な方針で実装をしてみたいと思います(きちんと書くのであれば libuv とか使うのが良さそうです)。

  • イベントベースは止めて、ループで回しながらポーリングする実装とする
  • ハンドシェイクから HTTP のリクエスト/レスポンスまでを処理の流れのまま実装する
  • Windows 依存コードも気にせずバリバリ使う

ビルド環境については、筆者の好みで Visual Studio 2019 を使うことにします。

サンプルの修正

修正したコードを全て解説すると長くなってしまうので、当記事ではポイントのみを取り上げます。
修正したサンプルは以下 にアップしてありますので、当記事で取り上げた個所以外が気になる場合はこちらを参照してみてください。
http3sharp - QuicheWindowsSample
※終了処理絡みが面倒だったのでクラス化してありますが、基本的には examples/http3-client.c を整理しただけなので C 言語チックな実装となっています

quiche のコンフィグ

初期化の流れは前述したので割愛し、コンフィグについて少し触れようと思います。
quiche では QUIC のコンフィグと HTTP/3 のコンフィグを別々に設定します。

HTTP/3 のコンフィグ

実装 : QuicheWrapper::CreateHttpStream

QPACK 関連の設定が二つとヘッダリストの最大登録数の設定が可能です。
0 を指定するとデフォルト値が使用されます。

    // HTTP/3 用のコンフィグを作成する
    quiche_h3_config* config = quiche_h3_config_new();
    if (config == nullptr)
    {
        fprintf(stderr, "failed to create HTTP/3 config\n");
        return nullptr;
    }
    // HTTP/3 固有の設定
    quiche_h3_config_set_max_header_list_size(config, 1024);            // SETTINGS_MAX_HEADER_LIST_SIZE の設定。ヘッダリストに登録できるヘッダの最大数
    quiche_h3_config_set_qpack_max_table_capacity(config, 0);           // SETTINGS_QPACK_MAX_TABLE_CAPACITY の設定。QPACK の動的テーブルの最大値
    quiche_h3_config_set_qpack_blocked_streams(config, 0);              // SETTINGS_QPACK_BLOCKED_STREAMS の設定。ブロックされる可能性のあるストリーム数

QUIC のコンフィグ

実装 : QuicheWrapper::CreateQuicheConfig

QUIC 関連の設定は以下の通りです。
数が多いので各項目の詳細はコメントを参照してみてください。

quiche_config* QuicheWrapper::CreateQuicheConfig()
{
    // 引数には QUIC のバージョンを渡す
    // バージョンネゴシエーションを試したい時は 0xbabababa を渡すこと
    quiche_config* config = quiche_config_new(QUICHE_PROTOCOL_VERSION);
    if (config == nullptr)
    {
        fprintf(stderr, "failed to create config\n");
        return nullptr;
    }

    // quiche に HTTP/3 の ALPN token を設定する
    // quiche.h に定義されている QUICHE_H3_APPLICATION_PROTOCOL を渡せばいい
    quiche_config_set_application_protos(config, (uint8_t*)QUICHE_H3_APPLICATION_PROTOCOL, sizeof(QUICHE_H3_APPLICATION_PROTOCOL) - 1);

    // QUIC 固有の設定
    // 生成した config に対して設定を適用していく(下記の設定値は quiche の example に準拠)
    // 以下に無い quiche_config_set_max_ack_delay, quiche_config_set_ack_delay_exponent はクライアントからは呼ばないこと(サーバから降ってきた値を使用する)
    quiche_config_set_max_idle_timeout(config, 100);
    quiche_config_set_max_packet_size(config, MAX_DATAGRAM_SIZE);                // UDP パケット最大サイズ。 Google の調査により QUIC では 1350 が推奨
    quiche_config_set_initial_max_data(config, 10000000);                        // initial_max_data の設定(コネクションに対する上限サイズ)
    quiche_config_set_initial_max_stream_data_bidi_local(config, 1000000);       // initial_max_stream_data_bidi_local の設定(ローカル始動の双方向ストリームの初期フロー制御値)
    quiche_config_set_initial_max_stream_data_bidi_remote(config, 1000000);      // initial_max_stream_data_bidi_remote の設定(ピア始動の双方向ストリームの初期フロー制御値)
    quiche_config_set_initial_max_stream_data_uni(config, 1000000);              // initial_max_stream_data_uni の設定(単方向ストリームの初期フロー制御値)
    quiche_config_set_initial_max_streams_bidi(config, 100);                     // initial_max_streams_bidi の設定(作成可能な双方向ストリームの最大値)
    quiche_config_set_initial_max_streams_uni(config, 100);                      // initial_max_streams_uni の設定(作成可能な単方向ストリームの最大値)
    quiche_config_set_disable_active_migration(config, true);                    // disable_active_migration の設定(コネクションマイグレーションに対応していない場合は true にする)
    //quiche_config_verify_peer(config, false);                                  // 証明書の検証の on/off 。オレオレ証明書を使う際には false にする

    // TLS の鍵情報のダンプ。WireShark 等でパケットキャプチャする際に用いる
    // 一般的に環境変数 SSLKEYLOGFILE で制御する
    {
        size_t buf;
        char buffer[1024];
        if (getenv_s(&buf, buffer, 1024, "SSLKEYLOGFILE"))
        {
            quiche_config_log_keys(config);
        }
    }

    return config;
}
  • バージョンネゴシエーションを試してみたい
    quiche_config_new の初期化時に与えるバージョンに 0x?a?a?a?a の書式パターンに従うバージョンを入れると、バージョンネゴシエーションを強制することが可能です。
    バージョンネゴシエーションを試してみる場合は、ハンドシェイクの処理の前に QuicheWrapper::SendQuicheWrapper::Receive を 1 セット追加してください。
    (やり取りが 1RTT 分増加するので、その処理が必要です)

  • quiche_config_set_initial_max_stream_xxx の設定の意味が分からない場合
    日本語でしっかり解説しているものはまだないので、 QUIC の draft を頑張って読んでみてください。

  • KEYLOG を用いた WireShark でのパケットキャプチャの手順
    これまた flano_yuki さんがまとめてくださっているので、そちらを参照してください。
    WiresharkでのQUICの復号(decrypt)
    quiche は QLOG に対応しているので、デバッグをするのであれば qlog を利用する方が良いとは思います(qlog に関しては記事 その参 で触れます)。

SCID の生成

実装 : QuicheWrapper::CreateQuicheConnection
SCID はクライアント側で一意になるように値を生成する必要がありますが、今回は手抜きで std::mt19937_64 を使って 16 byte のランダム値を生成しています。

    // Initial Packet で使用する SCID を乱数から生成する
    // SCID は QUIC バージョン 1 までは 20 バイト以内に抑える必要がある(今回は quiche の example の設定値に準拠)
    uint8_t scid[16] = {};
    std::random_device rd;
    std::mt19937_64 mt(rd());
    for (auto& id : scid)
    {
        id = mt() % 256;
    }

パケットの受信処理

実装 : QuicheWrapper::Receive
recv で受け取ったデータを quiche へ受け流せば OK です。

ssize_t QuicheWrapper::Receive(SOCKET sock, quiche_conn* conn)
{
    char buf[MAX_DATAGRAM_SIZE] = { 0 };
    ssize_t totalRead = 0;

    // UDP パケットを受信
    while (1)
    {
        ssize_t read = recv(sock, buf, sizeof(buf), 0);
        if (read < 0)
        {
            auto err = WSAGetLastError();
            if (err == WSAEWOULDBLOCK)
            {
                break;
            }
            perror("failed to read");
            return -1;
        }
        totalRead += read;

        // 受信したパケットを quiche に渡す
        ssize_t done = quiche_conn_recv(conn, reinterpret_cast<uint8_t*>(buf), read);
        if (done < 0)
        {
            fprintf(stderr, "failed to process packet: %zd\n", done);
            return -1;
        }
    }
    if (0 < totalRead)
    {
        fprintf(stderr, "recv %zd bytes\n", totalRead);
    }

    if (quiche_conn_is_closed(conn))
    {
        fprintf(stderr, "Unintended connection closed\n");
        return -1;
    }

    return 0;
}

パケットの送信処理

実装 : QuicheWrapper::Send
前述したように、 quiche_conn_send は quiche の内部ステータス(コネクションやストリーム状況)に応じて適切なデータを生成してくれるようです。
なので、実装は quiche_conn_send で返ってきたデータを send で送信するだけでよく、特に注意事項はありません。

void QuicheWrapper::Send(quiche_conn* _conn, SOCKET sock)
{
    static uint8_t out[MAX_DATAGRAM_SIZE] = { 0 };
    ssize_t totalSend = 0;

    while (1)
    {
        // quiche によって送信データ生成する
        // quiche_conn_send を呼ぶだけで、内部のステータス(コネクションやストリームの状況)に応じて適切なデータを生成してくれる
        ssize_t written = quiche_conn_send(_conn, out, sizeof(out));
        if (written == QUICHE_ERR_DONE)
        {
            break;
        }
        if (written < 0)
        {
            fprintf(stderr, "failed to create packet: %zd\n", written);
            return;
        }

        // quiche が生成したデータを UDP ソケットで送信する
        ssize_t sent = send(sock, (const char*)out, written, 0);
        if (sent != written)
        {
            perror("failed to send");
            return;
        }

        totalSend += sent;
    }

    if (0 < totalSend)
    {
        fprintf(stderr, "sent %zd bytes\n", totalSend);
    }
}

パケットの送受信をまとめる

ハンドシェイク時だろうが、 HTTP 通信時であろうが前述のパケットの受信・送信処理は常に行う必要があります。
Update のような関数を作り常に呼ばれるようにしておくと楽そうですので、まとめておきましょう。

int QuicheWrapper::Update()
{
    Send(_conn, _sock);
    return Receive(_sock, _conn);
}

ハンドシェイク

ハンドシェイクの終了に関しては quiche_conn_is_established で確認可能です。

    // handshake
    while (1)
    {
        if (0 > Update())
        {
            fprintf(stderr, "failed to recive.");
        }

        if (quiche_conn_is_established(_conn))
        {
            // 接続が確立してもすべてのパケットの受信が終わっていないことがある
            // 本来であれば非同期で Receive をまわしておくべきだがサンプルなので次でまとめて受け取る
            break;
        }

        // 送受信を少し待つ
        Sleep(16);
    }

HTTP リクエストの作成

実装 : QuicheWrapper::CreateHttpStream

quiche_h3_conn* QuicheWrapper::CreateHttpStream(quiche_conn* conn, const char* host)
{
    // HTTP/3 用のコンフィグを作成する
    quiche_h3_config* config = quiche_h3_config_new();
    if (config == nullptr)
    {
        fprintf(stderr, "failed to create HTTP/3 config\n");
        return nullptr;
    }
    // HTTP/3 固有の設定
    quiche_h3_config_set_max_header_list_size(config, 1024);            // SETTINGS_MAX_HEADER_LIST_SIZE の設定。ヘッダリストに登録できるヘッダの最大数
    quiche_h3_config_set_qpack_max_table_capacity(config, 0);           // SETTINGS_QPACK_MAX_TABLE_CAPACITY の設定。QPACK の動的テーブルの最大値
    quiche_h3_config_set_qpack_blocked_streams(config, 0);              // SETTINGS_QPACK_BLOCKED_STREAMS の設定。ブロックされる可能性のあるストリーム数

    // HTTP/3 通信用のストリームハンドルを作成(このタイミングではまだ通信しない)
    auto http3stream = quiche_h3_conn_new_with_transport(conn, config);
    quiche_h3_config_free(config);
    if (http3stream == nullptr)
    {
        fprintf(stderr, "failed to create HTTP/3 connection\n");
        return nullptr;
    }

    // HTTP リクエストの作成
    quiche_h3_header headers[] =
    {
        {
            .name = (const uint8_t*) ":method",
            .name_len = sizeof(":method") - 1,

            .value = (const uint8_t*) "GET",
            .value_len = sizeof("GET") - 1,
        },

        {
            .name = (const uint8_t*) ":scheme",
            .name_len = sizeof(":scheme") - 1,

            .value = (const uint8_t*) "https",
            .value_len = sizeof("https") - 1,
        },

        {
            .name = (const uint8_t*) ":authority",
            .name_len = sizeof(":authority") - 1,

            .value = (const uint8_t*)host,
            .value_len = strlen(host),
        },

        {
            .name = (const uint8_t*) ":path",
            .name_len = sizeof(":path") - 1,

            .value = (const uint8_t*) "/",
            .value_len = sizeof("/") - 1,
        },

        {
            .name = (const uint8_t*) "user-agent",
            .name_len = sizeof("user-agent") - 1,

            .value = (const uint8_t*) "quiche",
            .value_len = sizeof("quiche") - 1,
        },
    };
    // quiche にヘッダリストを登録する(このタイミングではまだ通信は実施されない)
    int64_t stream_id = quiche_h3_send_request(http3stream, conn, headers, 5, true);
    fprintf(stderr, "sent HTTP request %" PRId64 "\n", stream_id);

    return http3stream;
}
  • HTTP/3 用のコンフィグ
    SETTINGS_QPACK_MAX_TABLE_CAPACITYSETTINGS_QPACK_BLOCKED_STREAMS の詳細については、 QPACK の Draft を確認してください。
  • HTTP リクエストヘッダの dynamic table への登録
    quiche はまだ QPACK の dynamic table への対応が完了していません(0.4.0 で decoder が未実装)。
    aioquic では対応しているようなので、 dynamic table を試して見たい場合は aioquic を使ってみるのをお勧めします(筆者も動作は未確認です)。
  • 通信のタイミング
    quiche_h3_send_request とかいかにも通信しそうな感じの関数名ですが、実際の通信は send 側で行われます。
    基本的には「quiche の関数では通信は行われない」という認識でいると、処理の流れが分かり易く頭に入ってくると思います。

HTTP レスポンスの受信

実装 : QuicheWrapper::PollHttpResponse

// HTTP のレスポンス待ちをする関数
// -1 : エラー終了、 0 : 正常終了、 1 : 継続
int QuicheWrapper::PollHttpResponse(quiche_conn* conn, quiche_h3_conn* http3stream)
{
    quiche_h3_event* ev;
    char buf[MAX_DATAGRAM_SIZE] = { 0 };

    // quiche 側に HTTP のイベントが来ているかチェック
    // イベントを取り切るので while で回す
    while (1)
    {
        // ヘッダ受信、ボディ受信、ストリームのクローズの 3 種のイベントがある
        int64_t s = quiche_h3_conn_poll(http3stream, conn, &ev);
        if (s < 0)
        {
            break;
        }
        auto ev_type = quiche_h3_event_type(ev);

        switch (ev_type)
        {
            case QUICHE_H3_EVENT_HEADERS:
            {
                // HTTP ヘッダの受信完了
                // quiche_h3_event_for_each_header にコールバック関数を渡してヘッダを受け取る
                if (quiche_h3_event_for_each_header(ev, for_each_header, nullptr) != 0)
                {
                    perror("failed to process headers");
                    // ヘッダ不正でもクローズしたいので継続
                }
                // body の有無をチェックしたい場合は quiche_h3_event_headers_has_body で確認できる
                if (!quiche_h3_event_headers_has_body(ev))
                {
                    printf("no body");
                    // クローズ処理あるので継続
                }
                break;
            }

            case QUICHE_H3_EVENT_DATA:
            {
                // HTTP ボディの受信完了
                // ヘッダとは違いこちらはバッファを受け渡す形式
                // buf のサイズより受信データが小さい場合は再度呼び出すと途中からコピーしてくれる → 取りきれるまでループで回す必要がある
                while (1)
                {
                    ssize_t len = quiche_h3_recv_body(http3stream, conn, s, reinterpret_cast<uint8_t*>(buf), sizeof(buf));
                    if (len > 0)
                    {
                        _body.append(buf);
                    }
                    else
                    {
                        break;
                    }
                }
                break;
            }

            case QUICHE_H3_EVENT_FINISHED:
            {
                // ダウンロード終了
                printf("download finishe. HTTP body:\n %.*s", (int)_body.size(), _body.c_str());

                // ストリームがクローズされたので後始末
                quiche_h3_conn_free(http3stream);
                quiche_h3_event_free(ev);
                if (quiche_conn_close(conn, true, 0, nullptr, 0) < 0)
                {
                    perror("failed to close connection\n");
                    return -1;
                }
                else
                {
                    return 0;
                }
            }
        }
        quiche_h3_event_free(ev);
    }

    return 1;
}

quiche_h3_event_for_each_headerquiche_h3_recv_body でデータの受け取り方が違うので注意が必要です。
ヘッダを保存したい場合は第三引数 argp に任意のポインタを渡せるので、これを経由すると良いようです(コールバック関数の最後の引数として入ってくる)。
また、quiche_h3_event_for_each_header に渡すコールバック関数で 0 以外を返すと処理を中断することができます。

サンプルの動作確認

主要な実装のポイントを押さえた所で、いよいよ修正したサンプルを動かしてみます。
前述した通り、今回修正したサンプルは http3sharpExample\QuicheWindowsSample としてアップしてあります。

サンプルのビルド

ビルドには Visual Studio 2019 が必要です。
Re: C#(Unity)でHTTP/3通信してみる その壱 で quiche をビルドした状態であれば、そのままサンプルもビルド・起動できます(quiche のビルド成果物のパスを変えている場合は適宜配置を修正してください)。

サンプルの実行

コマンドライン引数か main.cpp 内の定数を書き換える事により接続先のサーバを指定可能です。
今回は当記事前半で建てた aioquic のサーバと通信を行ってみます。

以下、実行の半生ログです(【】は追記コメント)。
「パケットの受信処理」で書いたように、ハンドシェイク時のパケットの取得が分断されているので少し見づらくなっているので少しログを加工してあります。
1-RTT ハンドシェイク実行からの HTTP リクエスト/レスポンス送受信の流れを追うことができます。

【Initial Packet の送付】
[quiche DEBUG]quiche::tls: 2daf0f8e09de91834035b95c7a0bb2fe write message lvl=Initial len=224
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx pkt Initial version=ff00001b dcid=a5efb3713644c4bc4d5682ac863e3cdd scid=2daf0f8e09de91834035b95c7a0bb2fe len=1157 pn=0
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm CRYPTO off=0 len=224
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm PADDING len=913
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=993.1008ms latest_rtt=0ns srtt=None min_rtt=0ns rttvar=0ns loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=1200 app_limited=true congestion_recovery_start_time=None delivered=0 delivered_time=6.9079ms recent_delivered_packet_sent_time=6.9081ms app_limited_at_pkt=0  hystart=window_end=None last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
sent 1200 bytes

【Initial Packet(ACK)/ Handshake パケット/ 1-RTT パケット 受信】
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Initial version=ff00001b dcid=2daf0f8e09de91834035b95c7a0bb2fe scid=134b4cd3d66862d0 token= len=118 pn=0
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm ACK delay=510 blocks=[0..0]
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe packet newly acked 0
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=none latest_rtt=35.1872ms srtt=Some(35.1872ms) min_rtt=35.1872ms rttvar=17.5936ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=0 app_limited=true congestion_recovery_start_time=None delivered=1200 delivered_time=6.233ms recent_delivered_packet_sent_time=41.421ms app_limited_at_pkt=0  hystart=window_end=None last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm CRYPTO off=0 len=90
[quiche DEBUG]quiche::tls: 2daf0f8e09de91834035b95c7a0bb2fe set write secret lvl=Handshake
[quiche DEBUG]quiche::tls: 2daf0f8e09de91834035b95c7a0bb2fe set read secret lvl=Handshake
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Handshake version=ff00001b dcid=2daf0f8e09de91834035b95c7a0bb2fe scid=134b4cd3d66862d0 len=1095 pn=1
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm CRYPTO off=0 len=1073
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Handshake version=ff00001b dcid=2daf0f8e09de91834035b95c7a0bb2fe scid=134b4cd3d66862d0 len=1007 pn=2
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm CRYPTO off=1073 len=984
[quiche DEBUG]quiche::tls: 2daf0f8e09de91834035b95c7a0bb2fe write message lvl=Handshake len=52
[quiche DEBUG]quiche::tls: 2daf0f8e09de91834035b95c7a0bb2fe set write secret lvl=OneRTT
[quiche DEBUG]quiche::tls: 2daf0f8e09de91834035b95c7a0bb2fe set read secret lvl=OneRTT
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe connection established: proto=Ok("h3-27") cipher=Some(AES256_GCM) curve=Some("X25519") sigalg=Some("rsa_pss_rsae_sha256") resumed=false TransportParams { original_connection_id: None, max_idle_timeout: 60000, stateless_reset_token: None, max_packet_size: 65527, initial_max_data: 1048576, initial_max_stream_data_bidi_local: 1048576, initial_max_stream_data_bidi_remote: 1048576, initial_max_stream_data_uni: 1048576, initial_max_streams_bidi: 128, initial_max_streams_uni: 128, ack_delay_exponent: 3, max_ack_delay: 25, disable_active_migration: false, active_conn_id_limit: 8 }
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Short dcid=2daf0f8e09de91834035b95c7a0bb2fe key_phase=false len=133 pn=3
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm CRYPTO off=0 len=89
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm STREAM id=3 off=0 len=8 fin=false
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm STREAM id=7 off=0 len=1 fin=false
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm STREAM id=11 off=0 len=1 fin=false
recv 2470 bytes
connection established: h3-27
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe open GREASE stream 14
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe tx frm GREASE stream=0
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe tx frm HEADERS stream=0 len=21 fin=true


【Initial Packet(ACK) / Handshale Packet(ACK) / 1-RTT Packet 送信】
sent HTTP request 0
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx pkt Initial version=ff00001b dcid=134b4cd3d66862d0 scid=2daf0f8e09de91834035b95c7a0bb2fe len=1199 pn=1
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm ACK delay=8889 blocks=[0..0]
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm PADDING len=1177
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=none latest_rtt=35.1872ms srtt=Some(35.1872ms) min_rtt=35.1872ms rttvar=17.5936ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=0 app_limited=true congestion_recovery_start_time=None delivered=1200 delivered_time=76.0566ms recent_delivered_packet_sent_time=111.2442ms app_limited_at_pkt=1200  hystart=window_end=None last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx pkt Handshake version=ff00001b dcid=134b4cd3d66862d0 scid=2daf0f8e09de91834035b95c7a0bb2fe len=77 pn=0
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm ACK delay=6325 blocks=[1..2]
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm CRYPTO off=0 len=52
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=4.7315ms latest_rtt=35.1872ms srtt=Some(35.1872ms) min_rtt=35.1872ms rttvar=17.5936ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=111 app_limited=true congestion_recovery_start_time=None delivered=1200 delivered_time=90.6514ms recent_delivered_packet_sent_time=125.8388ms app_limited_at_pkt=1200  hystart=window_end=None last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe dropped epoch 0 state
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx pkt Short dcid=134b4cd3d66862d0 key_phase=false len=52 pn=0
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm ACK delay=5660 blocks=[3..3]
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm STREAM id=2 off=0 len=26 fin=false
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=106.5174ms latest_rtt=35.1872ms srtt=Some(35.1872ms) min_rtt=35.1872ms rttvar=17.5936ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=173 app_limited=true congestion_recovery_start_time=None delivered=1200 delivered_time=107.9099ms recent_delivered_packet_sent_time=143.0974ms app_limited_at_pkt=1200  hystart=window_end=Some(0) last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx pkt Short dcid=134b4cd3d66862d0 key_phase=false len=21 pn=1
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm STREAM id=6 off=0 len=1 fin=false
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=48.3692ms latest_rtt=35.1872ms srtt=Some(35.1872ms) min_rtt=35.1872ms rttvar=17.5936ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=204 app_limited=true congestion_recovery_start_time=None delivered=1200 delivered_time=166.0613ms recent_delivered_packet_sent_time=201.2488ms app_limited_at_pkt=1200  hystart=window_end=Some(0) last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx pkt Short dcid=134b4cd3d66862d0 key_phase=false len=21 pn=2
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm STREAM id=10 off=0 len=1 fin=false
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=4.6312ms latest_rtt=35.1872ms srtt=Some(35.1872ms) min_rtt=35.1872ms rttvar=17.5936ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=235 app_limited=true congestion_recovery_start_time=None delivered=1200 delivered_time=209.7955ms recent_delivered_packet_sent_time=244.9829ms app_limited_at_pkt=1200  hystart=window_end=Some(0) last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx pkt Short dcid=134b4cd3d66862d0 key_phase=false len=46 pn=3
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm STREAM id=14 off=0 len=26 fin=false
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=exp latest_rtt=35.1872ms srtt=Some(35.1872ms) min_rtt=35.1872ms rttvar=17.5936ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=291 app_limited=true congestion_recovery_start_time=None delivered=1200 delivered_time=262.5252ms recent_delivered_packet_sent_time=297.7129ms app_limited_at_pkt=1200  hystart=window_end=Some(0) last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx pkt Short dcid=134b4cd3d66862d0 key_phase=false len=79 pn=4
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe tx frm STREAM id=0 off=0 len=59 fin=true
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=exp latest_rtt=35.1872ms srtt=Some(35.1872ms) min_rtt=35.1872ms rttvar=17.5936ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=380 app_limited=true congestion_recovery_start_time=None delivered=1200 delivered_time=296.6682ms recent_delivered_packet_sent_time=331.8558ms app_limited_at_pkt=1200  hystart=window_end=Some(0) last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
sent 1614 bytes

【HTTP レスポンスの受信】
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Short dcid=2daf0f8e09de91834035b95c7a0bb2fe key_phase=false len=20 pn=4
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm HANDSHAKE_DONE
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe dropped epoch 1 state
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm PADDING len=1
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Short dcid=2daf0f8e09de91834035b95c7a0bb2fe key_phase=false len=24 pn=5
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm ACK delay=127 blocks=[0..0]
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe packet newly acked 0
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=258.028075ms latest_rtt=239.4598ms srtt=Some(60.594275ms) min_rtt=35.1872ms rttvar=64.00935ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=207 app_limited=true congestion_recovery_start_time=None delivered=1262 delivered_time=33.7304ms recent_delivered_packet_sent_time=273.1906ms app_limited_at_pkt=0  hystart=window_end=Some(0) last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Short dcid=2daf0f8e09de91834035b95c7a0bb2fe key_phase=false len=24 pn=6
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm ACK delay=128 blocks=[0..1]
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe packet newly acked 1
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=376.886374ms latest_rtt=240.1273ms srtt=Some(82.907902ms) min_rtt=35.1872ms rttvar=92.634268ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=176 app_limited=true congestion_recovery_start_time=None delivered=1293 delivered_time=7.0225ms recent_delivered_packet_sent_time=291.1431ms app_limited_at_pkt=0  hystart=window_end=Some(0) last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Short dcid=2daf0f8e09de91834035b95c7a0bb2fe key_phase=false len=24 pn=7
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm ACK delay=850 blocks=[0..2]
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe packet newly acked 2
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=396.537451ms latest_rtt=202.1771ms srtt=Some(96.966551ms) min_rtt=35.1872ms rttvar=97.593ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=145 app_limited=true congestion_recovery_start_time=None delivered=1324 delivered_time=4.6652ms recent_delivered_packet_sent_time=305.3856ms app_limited_at_pkt=0  hystart=window_end=Some(0) last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Short dcid=2daf0f8e09de91834035b95c7a0bb2fe key_phase=false len=24 pn=8
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm ACK delay=227 blocks=[0..3]
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe packet newly acked 3
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=367.750317ms latest_rtt=171.5443ms srtt=Some(106.061769ms) min_rtt=35.1872ms rttvar=91.385187ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=89 app_limited=true congestion_recovery_start_time=None delivered=1380 delivered_time=4.4311ms recent_delivered_packet_sent_time=318.4361ms app_limited_at_pkt=0  hystart=window_end=Some(0) last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Short dcid=2daf0f8e09de91834035b95c7a0bb2fe key_phase=false len=70 pn=9
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm STREAM id=0 off=0 len=48 fin=false
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Short dcid=2daf0f8e09de91834035b95c7a0bb2fe key_phase=false len=1094 pn=10
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm STREAM id=0 off=48 len=1071 fin=true
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx pkt Short dcid=2daf0f8e09de91834035b95c7a0bb2fe key_phase=false len=24 pn=11
[quiche DEBUG]quiche: 2daf0f8e09de91834035b95c7a0bb2fe rx frm ACK delay=130 blocks=[0..4]
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe packet newly acked 4
[quiche DEBUG]quiche::recovery: 2daf0f8e09de91834035b95c7a0bb2fe timer=none latest_rtt=144.06ms srtt=Some(110.681547ms) min_rtt=35.1872ms rttvar=77.778447ms loss_time=[None, None, None] loss_probes=[0, 0, 0] cwnd=14520 ssthresh=18446744073709551615 bytes_in_flight=0 app_limited=true congestion_recovery_start_time=None delivered=1469 delivered_time=6.6013ms recent_delivered_packet_sent_time=340.2373ms app_limited_at_pkt=0  hystart=window_end=Some(0) last_round_min_rtt=None current_round_min_rtt=None rtt_sample_count=0 lss_start_time=None
recv 1440 bytes
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe stream id 11 is readable
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 1 bytes on stream 11
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe stream id 7 is readable
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 1 bytes on stream 7
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe stream id 3 is readable
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 1 bytes on stream 3
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe open peer's control stream 3
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 1 bytes on stream 3
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 1 bytes on stream 3
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 5 bytes on stream 3
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe rx frm SETTINGS max_headers=None, qpack_max_table=Some(4096), qpack_blocked=Some(16)  stream=3
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe stream id 0 is readable
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 1 bytes on stream 0
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 1 bytes on stream 0
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 46 bytes on stream 0
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe rx frm HEADERS len=46 stream=0
[quiche DEBUG]quiche::h3::qpack::decoder: Header count=0 base=0
[quiche DEBUG]quiche::h3::qpack::decoder: Indexed index=25 static=true
[quiche DEBUG]quiche::h3::qpack::decoder: Literal name_idx=92 static=true value="aioquic/0.8.7"
[quiche DEBUG]quiche::h3::qpack::decoder: Literal name_idx=6 static=true value="Sun, 17 May 2020 02:36:33 GMT"
[quiche DEBUG]quiche::h3::qpack::decoder: Literal name_idx=4 static=true value="1068"
[quiche DEBUG]quiche::h3::qpack::decoder: Indexed index=52 static=true
got HTTP header: :status=200
got HTTP header: server=aioquic/0.8.7
got HTTP header: date=Tue, 19 May 2020 18:10:02 GMT
got HTTP header: content-length=1068
got HTTP header: content-type=text/html; charset=utf-8
[quiche DEBUG]quiche::h3: 2daf0f8e09de91834035b95c7a0bb2fe stream id 0 is readable
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 1 bytes on stream 0
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 1 bytes on stream 0
[quiche DEBUG]quiche::h3::stream: 2daf0f8e09de91834035b95c7a0bb2fe read 1 bytes on stream 0
download finishe. HTTP body:
 <!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8"/>
        <title>aioquic</title>
        <link rel="stylesheet" href="/style.css"/>
    </head>
    <body>
        <h1>Welcome to aioquic</h1>
        <p>
            This is a test page for <a href="https://github.com/aiortc/aioquic/">aioquic</a>,
            a QUIC and HTTP/3 implementation written in Python.
        </p>

        <p>
            Congratulations, you loaded this page using HTTP/3!
        </p>

        <h2>Available endpoints</h2>
        <ul>
            <li><strong>GET /</strong> returns the homepage</li>
            <li><strong>GET /NNNNN</strong> returns NNNNN bytes of plain text</li>
            <li><strong>POST /echo</strong> returns the request data</li>
            <li>
                <strong>CONNECT /ws</strong> runs a WebSocket echo service.
                You must set the <em>:protocol</em> pseudo-header to <em>"websocket"</em>.
            </li>
            <li>There is also an <a href="/httpbin/">httpbin instance</a>.</li>
        </ul>
    </body>
</html>

まとめ

以上で「Re: C#(Unity)でHTTP/3通信してみる その弐 ~Windowsでquicheのサンプルを動かしてみる~」の手順は完了です。
丁寧に説明したので前回以上に長くなってしまいましたが、ポイントを押さえれば quiche はとても簡単に HTTP/3 通信を実装できます。
興味のある方は是非実装してみてください。

また、 quiche には HTTP/3 だけではなく QUIC で通信を行う API も用意されています。
もう少し深掘りしてみたい方は是非そちらも参照してみてください(quiche.h に HTTP/3 API 同様に定義があります)。

Next!! → Coming soon... (Re: C#(Unity)でHTTP/3通信してみる その参 ~Unityから使ってみる~)

おまけ : Rsut のデバッグ

Visual Studio 2019 では Rust の中にも入ってデバッグ可能です。
debug.PNG
ウォッチ式で変数の値も見れます。
便利!


  1. クライアントと一緒に解説しようとも思ったのですが、割と修正が多くてしんどい感じだったので今回は割愛します 

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

Unityでも宣言的UI使いたくない??

Unity でも宣言的 UI 使いたくない??

1 年前に Unity で 状態管理をするフレームワーク を作りました。
しかし、まだ辛いところがあったのです。

「宣言的に UI を書きたくない??」

宣言的 UI とは?

Wikipediaより

宣言的 UI は宣言型プログラミングを用いて構成された GUI、それを実現する手法である。GUI の生成・更新を変更前状態に基づいた更新命令によってコーディングするのではなく、あるべき状態を宣言してコーディングする。状態を分離することで UI の状態をより予測しやすいものにできる。テンプレートエンジンは静的テンプレートと動的変数の関係を宣言しているとみなせるため、更新された状態とテンプレートからテンプレートエンジンによって UI 生成をおこなって UI を更新する形は宣言的 UI といえる。そういった意味でも宣言的 UI 自体は古くから存在する GUI 実装手法の 1 つである。

簡単に言うと「この状態のときはこの UI にする」というのを設定することです。

HTML でいうと、「HPが10のときには下のようなHTMLにする」

<div>
  <span>HP:</span>
  <span>10</span>
</div>

見たいな感じですね

しかし、UI って動的な値を反映させてあげないといけないので愚直に宣言的 UI を実装しようとすると UI の構成要素を全部新しく作らないといけません。
これってかなり重たい処理で、Unity で言うと UI に反映させたい値(HP とか)を更新するたびに uGUI の構成要素を Instantiate するということです(逆に使わなくなった UI は、Destory するということ)

作ったもの

そこで今回作ったライブラリは、効率よくUIの変更を更新できるようになっています!

仕組み

参考にしたのは、React や Vue などの Web フロントエンドのフレームワークで使われている 仮想 DOM の概念です。

仮想 DOM

仮想 DOM の概念はシンプルなもので表示に使っている Object(Web なら DOM、Unity なら GameObject)を仮想的なもの(木構造になっているデータ構造)で表して、更新の際はその木構造の差分を計算して必要な変更だけをしてあげる。

というものです。

機能

この Veauty 自体の機能は主に2つで

  • 仮想 Object の Tree を作成
  • 差分の計算

があります。

ここで気になった人もいると思いますが、必要な変更だけをしてあげる が入ってないんですね。
この部分は来る UIElement に向けて 無駄に 抽象化をしていてこのライブラリを使って実装するようにしています。(詳細は次の章で)

使い方

先程も言ったとおりこのライブラリだけだと GameObject に反映出来ないので以下のライブラリを利用します。

今回はカウンターを作りながら使い方を見ていきます。

Image from Gyazo

インストール方法

Unity Package Manager を利用しているのでプロジェクトルート以下にある Packages/manifest.json

{
  "dependencies" : {
    ...
    "com.uzimaru.veauty": "https://github.com/uzimaru0000/Veauty.git",
    "com.uzimaru.veauty-gameobject": "https://github.com/uzimaru0000/Veauty-GameObject.git",
    ...
  }
}

と追記してエディタに行くとインストールされます.

ボイラープレート

若干のボイラープレート的な物を書かないといけないのでそれにコードを追加していく形で解説していきます。

// UIRoot.cs

using UnityEngine;
using UI = UnityEngine;
using Veauty;
using Veauty.VTree;
using Veauty.GameObject;

public class UIRoot : MonoBehaviour
{

  private VeautyObject veauty;

  void Start()
  {
    this.veauty = new VeautyObject(gameObject, Render, true);
  }

  void Render()
    => new Node("GameObject", IAttribute[] {}, IVTree[] {});
}

急にいろいろなクラスが出てきていますが順を追って説明していきます。
ここで作成された UIRoot クラスは Canvas に Attach してください。

UI を作成

察しのいい人は分かると思うのですが、Render メソッドに UI の定義を書いていきます。

GameObject の作成

早速、ただの GameObject は以下のように宣言します。

// UIRoot.cs

IVTree Render() =>
  new Node("GameObject", IAttribute[] {}, IVTree[] {});

Node クラスが何も component がついていない GameObject を生成する要素です。
第1引数の文字列は、GameObject の名前を示していてここの文字列が違うと前回とは違う要素だと判断して再描画されます。
第2引数の IAttribute の配列は、この GameObject の Component に何かしらの値を反映させるためのものです。(transform.position の変更とか)
最後の引数の IVTree の配列は、この GameObject の子要素の配列になります。

Component のついた GameObject の作成

このままでは何もすることが出来ないので GameObject に Component をつけていきます。
HorizontalLayoutGroup のついた GameObject を作成してみましょう。

// UIRoot.cs

IVTree Render() =>
  new Node<UI.HorizontalLayoutGroup>(
    "HorizontalLayoutGroup",
    new IAttribute[] {},
    new IVTree[] {}
  );

Node クラスの Generics に Attach したい Component の型をつけてあげるだけです。簡単ですね!

Button を作成する

カウンターの値を加算・減算するための Button を作成しましょう。
前のコードと同じように Button クラスのついたNodeを作成しましょう!

// UIRoot.cs

IVTree Render() =>
    new Node<UI.HorizontalLayoutGroup>(
        "HorizontalLayoutGroup",
        new IAttribute[] {},
        new IVTree[]
        {
            new Node<UI.Button>("Button", IAttribute[] {}, new IVTree[] {})
        }
    );

これで一旦動かして見ましょう。

スクリーンショット 2020-05-15 23.13.02.png

ヒエラルキー上で定義したような階層構造になっていることが分かると思います。
しかし、uGUI のButtonクラスは押すために Graphic クラスをtargetGraphic に設定しないといけないため現状では動きません。。。
そんな少し複雑になっている UI を作成するために使うのが Widget クラスです。

// ButtonWidget.cs

public class ButtonWidget : Widget
{
    private IAttribute[] attrs;
    private IVTree[] kids;

    public ButtonWidget(IAttribute[] attrs, IVTree[] kids)
    {
        this.attrs = attrs;
        this.kids = kids;
    }

    public override GameObject Init(GameObject go)
    {
        var image = go.AddComponent<UI.Image>();
        var btn = go.GetComponent<UI.Button>();
        btn.targetGraphic = image;

        return go;
    }

    public override IVTree Render() =>
        new Node<UI.Button>(
            "Button",
            this.attrs,
            this.kids
        );

    public override void Destroy(GameObject go) { }

    public override IVTree[] GetKids() => this.kids;
}

少し長いですが、こんな感じのコードです。
順を追って説明していきます。

Widget

今回のメインの Widget クラスを継承します。このクラスは抽象クラスになっているので以下のメソッドをオーバーライドしなければいけません。

  • GameObject Init(GameObject go)
  • IVTree Render()
  • void Destory(GameObject go)
  • IVTree[] GetKids()
Initメソッド

このメソッドは、実体化した GameObject の初期設定をするためのメソッドです。
今回でいうと、Image クラスを Attach してButton クラスの targetGraphic に設定しています。(Button クラスは Node クラスの Generics で設定済み)

Render メソッド

widget 内での UI の宣言です。
今回は、Node クラスにButton クラスをつけてコンストラクタで受け取った Attributes と子要素を渡しています。

Destory メソッド

この Widget が削除されるときに実行されるメソッドです(実はまだ未実装)

GetKids メソッド

子要素を返します。

早速ここで作成した、Button を使ってボタンを作成してみましょう!

IVTree Render() =>
    new Node<UI.HorizontalLayoutGroup>(
        "HorizontalLayoutGroup",
        new IAttribute[] {},
        new IVTree[]
        {
            new Button(IAttribute[] {}, new IVTree[] {})
        }
    );

これでボタンが生成されたと思います!

テキストに文字を指定する

ボタンは出来ましたが、中に入る Text が出来ていません。
とりあえず Text を出す Widget を作成します。

using Veauty.VTree;
using UnityEngine;
using Veauty;
using UI = UnityEngine.UI;

public class Text : Widget
{
    private IAttribute[] attrs;

    public Text(IAttribute[] attrs)
    {
        this.attrs = attrs;
    }

    public override IVTree[] GetKids() => new IVTree[0];

    public override GameObject Init(GameObject go)
    {
        var textComponent = go.GetComponent<UI.Text>();
        textComponent.font = Resources.GetBuiltinResource<Font>("Arial.ttf");
        textComponent.alignment = TextAnchor.MiddleCenter;
        textComponent.color = Color.black;

        return go;
    }

    public override IVTree Render() => new Node<UI.Text>("Text", attrs, GetKids());

    public override void Destroy(GameObject go) { }
}

Widget の中身は Button と同じようなものなので省略します。
さて、ここで文字を指定するにはどうしたら良いでしょう?
普通の Unity だったら UI.Texttext に表示したい文字を入れます。では、Veauty だったら?
答えは Attribute を使います。
上でも少し説明したように Attribute とは GameObject の Component に何かしらの値を反映させるためのもの です。
なので今回は UI.Texttext に表示したい文字列を反映させる Attribute を作成しましょう。

ValueAttribute

Text だと Widget の方とかぶってしまうので Value という名前にします。

// Value.cs

using Veauty;
using UI = UnityEngine.UI;

public class Value : IAttribute
{
    private string value;

    public Value(string value)
    {
        this.value = value;
    }

    public string GetKey() => "Value";

    public void Apply(GameObject obj)
    {
        var textComponent = obj.GetComponent<UI.Text>();
        if (textComponent)
        {
            textComponent.text = this.value;
        }
    }

    public bool Equals(IAttribute attr)
    {
        if (attr is Value other)
        {
            return this.value == other.value;
        }

        return false;
    }
}

IAttribute インターフェースで実装するメソッドは 3 つです。

string GetKey()

この Attribute を識別するためのものです。

void Apply(GameObject obj)

渡ってきた Object に対してこの Attribute がしたい操作を反映させます。

bool Equals(IAttribute attr)

渡ってきた IAttribute を見てこの Attribute と等しいかを判定します。

実際に使って見ましょう。

// UIRoot.cs

IVTree Render() =>
    new Node<UI.HorizontalLayoutGroup>(
        "HorizontalLayoutGroup",
        new IAttribute[] {},
        new IVTree[]
        {
        new Button(IAttribute[] {}, new IVTree[]
            {
                new Text(new IAttribute[] { new Value("↑") })
            }),
        new Text(new IAttribute[] { new Value("0") }),
        new Button(IAttribute[] {}, new IVTree[]
            {
                new Text(new IAttribute[] { new Value("↓") })
            }),
        }
    );

だんだん形が見えて来ましたね。

OnClick を実装する

Button に対する OnClick もAttribute として実装します。

コードは以下のようになります。

// OnClick.cs

using UnityEngine;
using UI = UnityEngine.UI;
using Events = UnityEngine.Events;
using Veauty;

public class OnClick : IAttribute
{
    private Events.UnityAction action;

    public OnClick(Events.UnityAction action)
    {
       this.action = action;
    }

    public string GetKey() => "OnClick";

    public void Apply(GameObject obj)
    {
        var button = obj.GetComponent<UI.Button>();
        if (button)
        {
            button.onClick.RemoveAllListeners();
            button.onClick.AddListener(this.action);
        }
    }

    public bool Equals(IAttribute attr)
    {
        if (attr is OnClick other)
        {
            return this.action == other.action;
        }

        return false;
    }
}

これを使うとこんな感じですね

// UIRoot.cs

IVTree Render() =>
    new Node<UI.HorizontalLayoutGroup>(
        "HorizontalLayoutGroup",
        new IAttribute[] {},
        new IVTree[]
        {
            new Button(new IAttribute[] { new OnClick(() => Debug.Log("↑")) }, new IVTree[]
                {
                    new Text(new IAttribute[] { new Value("↑") })
                }),
            new Text(new IAttribute[] { new Value("0") }),
            new Button(new IAttribute[] { new OnClick(() => Debug.Log("↓")) }, new IVTree[]
                {
                    new Text(new IAttribute[] { new Value("↓") })
                }),
        }
    );

これでボタンを押すと console に Log が出ると思います。

State を更新する

最後に State を更新してみましょう!
Veauty では State の更新をするために VeautyObjectSetState メソッドを使って State 更新用の関数を生成します。
コードで見るとこんな感じです。今回は、counter という int 型の値を State とします。

// UIRoot.cs

using UnityEngine;
using UI = UnityEngine.UI;
using Veauty;
using Veauty.VTree;
using Veauty.GameObject;

public class Sample : MonoBehaviour
{
    private VeautyObject veauty;
    private int counter = 0;
    private System.Action<int> setCounter;

    void Start()
    {
        this.veauty = new VeautyObject(gameObject, Render, true);
        this.setCounter = this.veauty.SetState<int>(n => this.counter = n);
    }

    IVTree Render() =>
        new Node<UI.HorizontalLayoutGroup>(
            "HorizontalLayoutGroup",
            new IAttribute[] { },
            new IVTree[]
            {
                new ButtonWidget(new IAttribute[] {new OnClick(() => this.setCounter(this.counter + 1))}, new IVTree[]
                {
                    new Text(new IAttribute[] {new Value("↑")})
                }),
                new Node("Display", new IAttribute[0], new IVTree[]
                {
                    new Text(new IAttribute[] {new Value($"{this.counter}")}),
                }),
                new ButtonWidget(new IAttribute[] {new OnClick(() => this.setCounter(this.counter - 1))}, new IVTree[]
                {
                    new Text(new IAttribute[] {new Value("↓")})
                }),
            }
        );
}

ここで State を OnClick 内で直接更新しないで this.setCounter を経由して居ることを確認してください。
これは、VeautyObject に State が変わったこと(再描画をしてほしいこと)を通知するためにこのような更新の仕方をしています。
普通にcounterを更新をしてしまうと State が変わったことを検知できないため再描画がされません。。。

肝心の this.setCounter ですが、Start メソッドで初期化しています。
VeautyObjectSetState メソッドに更新するための操作を渡してあげると更新のための関数が生成されます。
これを利用して State を更新すると再描画がされるといった仕組みです。

最後に

これが Veauty のおおよその使い方になります!

しかし、まだまだ不備があったりと完全なものではないので興味のある人は是非コントリビュートをしていただけるとありがたいです :bow:
(ちなみにここで Widget と Attribute の作り方を丁寧に説明したのは、uGUI 用の Widget や Attribute をまとめたライブラリの Veauty-uGUI に協力してほしいからです...!)

また、Veautyでこんなの作ったよ!というのもの常に受け付けているのでぜひ触って見てください!!

それでは!!

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

簡単なステートマシンを作ってみた

はじめに

これはArbor3とうまく自分の作成中のゲームを組み合わせられず, でもとりあえず簡単なステートマシンだけ欲しかった人が簡易的にステートマシン(有限オートマトン)を実装してみたので, それの記事です。

以下がそのコードです。

StateMachine.cs
using System;
using System.Collections.Generic;
using UniRx;

namespace Assets.Scripts.StateMachine
{
    public abstract class State<T> where T: struct{


        protected Dictionary<T, State<T>> NextState = new Dictionary<T, State<T>>();

        public void Connect(T message, State<T> state)
        {
            NextState[message] = state;
        }

        public readonly string Name;

        /// <summary>
        /// このStateに入ったときに発行されます。イベントの値は前のStateです
        /// </summary>
        /// <returns></returns>
        public abstract IObservable<State<T>> OnEnterAsObservable();

        /// <summary>
        /// このStateにから出る時に発行されます。イベントの値は次のStateです
        /// </summary>
        /// <returns></returns>
        public abstract IObservable<State<T>> OnExitAsObservable();

        protected State(string name)
        {
            Name = name;
        }

        public override string ToString()
        {
            return $"{Name} State({typeof(T)})";
        }
    }

    public class StateMachine<T> where T : struct
    {
        private class InnerState : State<T>
        {


            public Subject<State<T>> OnEnter;
            public Subject<State<T>> OnExit;

            public override IObservable<State<T>> OnEnterAsObservable() => OnEnter = OnEnter ?? new Subject<State<T>>();

            public override IObservable<State<T>> OnExitAsObservable() => OnExit = OnExit ?? new Subject<State<T>>();

            public InnerState(string name) : base(name)
            {
            }

            public InnerState SendMessage(T message)
            {
                return (InnerState)NextState[message];
            }
        }

        public readonly State<T> Entry;

        private InnerState current;
        public State<T> CurrentState => current;

        public string CurrentStateName => current?.Name;

        public StateMachine()
        {
            Entry = current = new InnerState(default);
        }

        /// <summary>
        /// メッセージを送出します
        /// </summary>
        /// <param name="message"></param>
        public void SendMessage(T message)
        {
            var next = current.SendMessage(message);
            var prev = current;

            current.OnExit?.OnNext(next);
            current = next;
            next.OnEnter?.OnNext(current);
        }

        private Dictionary<string, InnerState> states = new Dictionary<string, InnerState>();

        public State<T> Create(string name)
        {
            var c = new InnerState(name);

            states.Add(name, c); // 例外出すため

            return c;
        }

        public void Jump(string name)
        {
            var next = states[name];
            var prev = current;

            current.OnExit?.OnNext(next);
            current = next;
            next.OnEnter.OnNext(current);
        }

        public State<T> GetState(string name)
        {
            return states[name];
        }



    }
}

使い方

ステートマシンの定義

  1. メッセージ用のenumを作成します
  2. Createメソッドで状態を作成します
  3. State.ConnectメソッドでそのStateの状態でメッセージが来た場合に遷移する状態を定義します。
PlayerStateMachine.cs
using Assets.Scripts.StateMachine;

namespace Assets.Scripts.StateMachine
{
    public enum PlayerMessage
    {
        Start,
        TakeDamage,

    }

    class PlayerStateMachine : StateMachine<PlayerMessage>
    {
        public State<PlayerMessage> Alive { get; private set; } 
        public State<PlayerMessage> Dead { get; private set; }

        public PlayerStateMachine()
        {
            Alive = Create("Alive");
            Dead = Create("Dead");


            Entry.Connect(PlayerMessage.Start, Alive);

            Alive.Connect(PlayerMessage.TakeDamage, Dead);
        }
    }
}

ステートマシンの使用

  1. ステートマシンは必ずEntryから開始されます
  2. StateにはOnEnterAsObservableとOnExitAsObservableという状態になった時状態から遷移したときのIObservableを入れています
  3. SendMessageメソッドでメッセージを送って遷移します
UseStateMachine.cs
var playerState = new PlayerStateMachine();
playerState.Dead.OnEnterAsObservable().subscribe(_ =>{/*死んだ*/});

playerState.SendMessage(PlayerMessage.Start);

// playerがダメージをうけたら
playerState.SendMessage(PlayerMessage.TakeDamage);

まとめ

というわけで簡単なステートマシン作ってみました。拡張はしやすいかも??

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