e.blog

主にUnity/UE周りのことについてまとめていきます

HTC Viveの埋め込みカメラの映像をテクスチャとして取得・表示する

概要

HTC Viveのヘッドマウントディスプレイには、フロントカメラが埋め込まれています。
このカメラは、メニュー表示時にカメラを起動し外の状況を確認したり、ゲーム画面自体にオーバーレイで周りの状況を表示して、HMDを脱がなくても色々とできるように、という配慮がなされています。

カメラ自体は通常のWebカメラと違いはないので、Unityからカメラの映像をテクスチャとして取り込み、それを利用することができます。

WebCamTextureでも取得はできるようなのですが、SteamVRからもAPIが提供されているので、そちらを利用する方法をメモしておこうと思います。

なお、今回の記事はこちらのValveのコードを参考にしています(というかほぼコピー)。

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

public class ViveCam : MonoBehaviour
{
    [SerializeField]
    private bool _undistorted = true;

    [SerializeField]
    private bool _cropped = true;

    [SerializeField]
    private bool _followTrakking = false;

    [SerializeField]
    private Transform _target;

    [SerializeField]
    private Material _material;

    #region ### MonoBehaviour ###
    private void OnEnable()
    {
        EnableSteamVRCamera();
    }

    private void OnDisable()
    {
        DisableSteamVRCamera();
    }

    private void Update()
    {
        UpdateCameraTexture();
    }
    #endregion ### MonoBehaviour ###

    private void UpdateCameraTexture()
    {
        var source = SteamVR_TrackedCamera.Source(_undistorted);
        var texture = source.texture;

        if (texture == null)
        {
            return;
        }

        _material.mainTexture = texture;

        float aspect = (float)texture.width / texture.height;

        if (_cropped)
        {
            var bounds = source.frameBounds;
            _material.mainTextureOffset = new Vector2(bounds.uMin, bounds.vMin);

            float du = bounds.uMax - bounds.uMin;
            float dv = bounds.vMax - bounds.vMin;

            _material.mainTextureScale = new Vector2(du, dv);

            aspect *= Mathf.Abs(du / dv);
        }
        else
        {
            _material.mainTextureOffset = Vector2.zero;
            _material.mainTextureScale = new Vector2(1f, -1f);
        }

        _target.localScale = new Vector3(1f, 1f / aspect, 1);

        if (_followTrakking)
        {
            if (source.hasTracking)
            {
                var t = source.transform;
                _target.localPosition = t.pos;
                _target.localRotation = t.rot;
            }
        }
    }

    private void EnableSteamVRCamera()
    {
        var source = SteamVR_TrackedCamera.Source(_undistorted);
        source.Acquire();

        // カメラが認識されていなかったらdisableにする
        if (!source.hasCamera)
        {
            enabled = false;
        }
    }

    private void DisableSteamVRCamera()
    {
        _material.mainTexture = null;

        var source = SteamVR_TrackedCamera.Source(_undistorted);
        source.Release();
    }
}

ポイントは、SteamVR_TrackedCameraを使って該当カメラを有効化し、さらに有効な場合に、Updateメソッド内で、カメラの映像をTextureとして受け取ってそれを更新する、というものです。
外部カメラからの映像をなにかに使ったり、あるいはゲーム中の安全確保のために、一定距離近づいたら外部カメラ有効化してそれを表示する、みたいな使い方もできるかもしれません。

ということで、使い方を知りたかったのでメモとして書きとどめておきます。

UnityでPivotを指定して同一スケーリングを行うスクリプト

https://i.gyazo.com/50ceb29458b165477af349d93fc7fcc5.gif

概要

Unityでは標準ではスケールのPivot位置を変更することができません。
とはいえそうした要望はきっと多いと思います。
実際にググって見ると、モデリングツールで変更しろ、だとか、Empty Objectを作って子にしてからスケーリングしろ、だとか、色々な情報が出てきます。

が、やはりスクリプトからPivot位置を指定してスケールを掛けたい場合もきっとあると思います。
そのために階層構造をいじるのはなんか違う気がしますし、なにより気持ち悪いです。

ということで、スクリプトからそれを実現できないかと思い、色々といじってみてうまく行ったのでそれを書きとどめたいと思います。

※ ただし、今回の例ではスケールに利用する値はすべて同じ値を想定しています。そうでない場合はSkewの影響でx,y,zの3要素で取得できないため別の処理が必要になります。

どのように動くかは冒頭のアニメーションGifを見てみてください。
ちょっと分かりづらいかもしれませんが赤いGizmoがPivotです。

コード

それほど長くないので、まずはコード全文を載せます。
コードは以下のような感じで実装しました。

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

public static class PivotScaling
{
    /// <summary>
    /// ターゲットのTransformを、Pivotを基点にスケーリングする
    /// </summary>
    /// <param name="target">対象Trasnform</param>
    /// <param name="pivot">スケーリング基点</param>
    /// <param name="scale">スケーリング値</param>
    public static void Scale(Transform target, Vector3 pivot, float scale)
    {
        // 指定スケールになるように元のスケールを求める
        Vector3 originalScale = GetScale(target.localToWorldMatrix);

        // 親のマトリクス(Pivot用)
        Matrix4x4 parentMat = Matrix4x4.identity;
        parentMat[0, 0] = scale / originalScale.x;
        parentMat[1, 1] = scale / originalScale.x;
        parentMat[2, 2] = scale / originalScale.x;

        parentMat[0, 3] = pivot.x;
        parentMat[1, 3] = pivot.y;
        parentMat[2, 3] = pivot.z;

        // 自身のマトリクス
        Matrix4x4 mat = target.localToWorldMatrix;

        // 親の原点からの相対位置に移動させる
        Matrix4x4 transMat = Matrix4x4.identity;
        transMat[0, 3] = -pivot.x;
        transMat[1, 3] = -pivot.y;
        transMat[2, 3] = -pivot.z;

        Matrix4x4 result = parentMat * transMat * mat;

        target.position = GetPosition(result);
        target.localScale = GetScale(result);
        target.rotation = GetRotation(result);
    }

    /// <summary>
    /// マトリクスから位置を取り出す
    /// </summary>
    static Vector3 GetPosition(Matrix4x4 matrix)
    {
        return matrix.GetColumn(3);
    }

    /// <summary>
    /// マトリクスの回転行列からQuatenionを取り出す
    /// </summary>
    static Quaternion GetRotation(Matrix4x4 matrix)
    {
        return Quaternion.LookRotation(matrix.GetColumn(2), matrix.GetColumn(1));
    }

    /// <summary>
    /// マトリクスからスケールを取り出す
    /// 
    /// @warning ただし、uniform-scaleのみに対応
    /// </summary>
    static Vector3 GetScale(Matrix4x4 matrix)
    {
        float x = Mathf.Sqrt(matrix[0, 0] * matrix[0, 0] + matrix[0, 1] * matrix[0, 1] + matrix[0, 2] * matrix[0, 2]);
        float y = Mathf.Sqrt(matrix[1, 0] * matrix[1, 0] + matrix[1, 1] * matrix[1, 1] + matrix[1, 2] * matrix[1, 2]);
        float z = Mathf.Sqrt(matrix[2, 0] * matrix[2, 0] + matrix[2, 1] * matrix[2, 1] + matrix[2, 2] * matrix[2, 2]);
        return new Vector3(x, y, z);
    }
}

主な部分は最初のScaleメソッドだけです。
ここで行っていることは、手動でEmpty Objectを作って操作していることをコードでやっているに過ぎません。

大まかになにをしているかと言うと、以下のような手順で計算を行っています。

  1. 親のマトリクス相当の計算(スケールと平行移動)(*1)
  2. 対象Transformの座標を、親空間での位置に変換するための行列生成
  3. それらをかけ合わせて最終的な結果を取得

といった流れです。

最初のところでは、親となるEmpty Object相当のマトリクスを生成しています。
冒頭でも書いたように、同じ値のスケール(uniform scaling)以外の場合は結果が異なってしまうため想定していません。
(とはいえ、スケールの成分がそれぞれ異なるというケースはそこまで多くないと思います)

そのため、引数で受け取るのはfloatのスケール値です。
それを、現在のスケール値で割っているのは、最終的な計算結果のスケールが、指定されたスケールになるようにするためです。
(じゃないと、同じスケール値を指定してもどんどん拡大していってしまう)

parentMatのスケールを設定してるのは対角成分です。
スケールは以下のように対角部分に現れるので、それを直接書き込んでいるわけですね。
行列表現をすると以下のようになります。

\begin{vmatrix} S_x & 0 & 0 & 0 \\ 0 & S_y & 0 & 0 \\ 0 & 0 & S_z & 0 \\ 0 & 0 & 0 & 1 \end{vmatrix}

その後、親の平行移動成分を書き込んでいます。いわゆるPivot位置ですね。
平行移動成分は最後の行に格納されるため、[n, 3]要素に書き込んでいます。
このあたりは座標変換行列の基本ですね。

\begin{vmatrix} 0 & 0 & 0 & T_x \\ 0 & 0 & 0 & T_y \\ 0 & 0 & 0 & T_z \\ 0 & 0 & 0 & 1 \end{vmatrix}

※ 行列は、扱うシステムによってオーダーが異なります(行オーダー/列オーダーの2種類)。Unityの場合は上記のように「列」となります。

親の変換行列ができたら、次に生成している行列は、「親空間での座標位置への変換行列」です。
Empty Objectを生成して実行する場合は、Unityが自動的に座標変換してくれます。
実際にヒエラルキー状でドラッグ&ドロップすると、位置は変わらないものの、インスペクタの表示は変わりますね。
それを表現しているというわけです。

そして最後は、求めた各行列を、対象のTransformから得られた現在の行列に掛けてあげるだけです。

Matrix4x4 result = parentMat * transMat * mat;

これで求めたい行列が求まりました。

ただ、(なぜか)残念なことに、Transformに直接マトリクスを設定する方法がないため、行列から「位置」「回転」「スケール」を個別に取り出して設定しています。
それを行っているのが、サポート的に実装したGet****メソッドたちです。

サポートメソッド

それぞれのサポートメソッドが行っている処理は以下のようになっています。

GetPosition

行列から平行移動要素を取り出します。
といっても、設定と同じく3カラム目(0始まりなので4番目)の値をそのままベクトルとして抽出するだけです。

/// マトリクスから位置を取り出す
/// </summary>
static Vector3 GetPosition(Matrix4x4 matrix)
{
    return matrix.GetColumn(3);
}

平行移動要素はとてもシンプルですね。

GetRotation

続いて回転。
回転の取り出しは、行列から、オブジェクトの「ワールド空間での」各軸の方向が取り出せることを利用して、Quaternion.LookRotationを用いてクォータニオンを取得します。

/// <summary>
/// マトリクスの回転行列からQuatenionを取り出す
/// </summary>
static Quaternion GetRotation(Matrix4x4 matrix)
{
    return Quaternion.LookRotation(matrix.GetColumn(2), matrix.GetColumn(1));
}

matrix.GetColumn(2)で2番目のカラムを取り出していますが、これが、ワールド空間でのforwardのベクトル(非正規化)になります。
同様に、matrix.GetColumn(1)upベクトルに相当するため、このふたつを用いてクォータニオンを生成しているわけです。

GetScale

最後にスケール。
行列をある程度知っている人であれば、回転行列とスケールは掛け合わされて保存されていることを知っていると思います。
なので、値としてはとても複雑な値になってしまっています。
果たしてここから目的の値を取り出せるの? と思うかもしれませんが、可能です。

というのも、回転行列の値は、スケールが1であれば単位ベクトルを回転したものになっています。
裏を返せば、単位ベクトルになっていない=スケールがかかっている、というわけです。

と、ちょっと長く書きましたが、要は該当要素の「長さ」を計算してやることで、まさに「スケール」が求められる、というわけなのです。
実際のメソッドの中の処理は以下のようになります。

/// <summary>
/// マトリクスからスケールを取り出す
/// 
/// @warning ただし、uniform-scaleのみに対応
/// </summary>
static Vector3 GetScale(Matrix4x4 matrix)
{
    float x = Mathf.Sqrt(matrix[0, 0] * matrix[0, 0] + matrix[0, 1] * matrix[0, 1] + matrix[0, 2] * matrix[0, 2]);
    float y = Mathf.Sqrt(matrix[1, 0] * matrix[1, 0] + matrix[1, 1] * matrix[1, 1] + matrix[1, 2] * matrix[1, 2]);
    float z = Mathf.Sqrt(matrix[2, 0] * matrix[2, 0] + matrix[2, 1] * matrix[2, 1] + matrix[2, 2] * matrix[2, 2]);
    return new Vector3(x, y, z);
}

ただ、お気づきの方もいると思いますが、それぞれの要素の長さを計算しているわけですが、各要素が「個別に」スケールが書けられていた場合、不可逆な値になってしまっています。
これが、各スケールが同じでないとならない理由です。

しかしUnityにはlossyScaleというプロパティがあります。
これは、この回転とスケールの問題を「ある程度」予測して計算を行い、「それっぽい」値を返してくれるプロパティです。
(なので場合によっては誤差が出る)

いちおう計算する方法はあるようなのですが、かなり複雑なので今回は調べるのを断念しました。
そもそも今回の実装はuniformなスケールだけのケースで利用する目的だったため必要なかったのもあります。
とはいえ、uniformスケールであれば問題なく使えるので有用だと思います。

異空間から転送されてきたように演出するマスクシェーダ

概要

今作っているコンテンツで、なにもない空間からオブジェクトが転送されてきたような演出をしたいと思い、そのために色々シェーダを書いたのでそのメモです。
以下の画像を見てもらうとどういう効果かイメージしやすいと思います。

https://i.gyazo.com/e357fe60d5927ed0c1eec2e97da3f644.gif

今回はこれを実装するにあたって色々ハマったり勉強になったりした点を書いていきたいと思います。

なお、こちらで解説しているシェーダなどについてはGithubで公開しているので動作を見たい方はそちらをダウンロードして確認ください。

github.com

まずは方針決め

Deferred Shadingだと、最初にMeshをレンダリングして、その後にBoolean演算を行ってくり抜く、みたいなことができるようです。
(↓こんな感じの。みんな大好き凹みTips)

tips.hecomi.com

ただ、作っているコンテンツがVRなのでDeferredとは相性が悪く、Forward Renderingで行うために、今回はステンシルバッファを利用してレンダリングするように挑戦してみました。
(まぁ結果としてはPassが増えて、そもそも重そうになってしまったのでこれを採用するかは未知数ですが・・;)

方針

方針は、前述のように「ステンシルバッファ」を利用して、マスク対象のオブジェクトの「背面だけ」をどうにかしてレンダリングするようにします。
図で言うと以下の部分ですね。

f:id:edo_m18:20170307023731p:plain

最初に考え始めたときは、カリングやら深度テストやらをごにょごにょすればすぐだろーくらいの感じで考えていたのですが、これがなかなかどうして、色々と考慮しないとならないことが多く、地味にハマりました;
が、おかげでだいぶ深度テストとステンシルバッファの扱いのイメージがしっかりとついた気がします。

基本は3マテリアル

今回のサンプルは3つのマテリアル(=3つのシェーダ)を作成しました。
ただ、各々のシェーダ内で複数パス利用しているので、全体ではだいぶパスが増えています。

登場人物としては「マスクオブジェクト」とマスク対象の「ターゲットオブジェクト」のふたつ。

各シェーダが連携しながらうまくマスクデータを作るようにしています。
レンダリングの順番としては、

  1. マスクオブジェクトをレンダリングして、マスクエリアにマークを付ける ... Mask.shader *1
  2. ターゲットオブジェクトをレンダリングして、ターゲットオブジェクトのクリップ面とそれ以外を分ける ... TargetMash.shader
  3. マスクオブジェクト側で、クリップ面(断面)をレンダリング ... MaskRender.shader
  4. マスクオブジェクトの表面を、ColorMask 0レンダリング(つまり深度値のみ書き込み) ... MaskRender.shader
  5. (2)で収集した「マスクオブジェクトの、カメラから見てマスクオブジェクトから隠れている部分」以外の部分のDepthをクリア ... MaskRender.shader

という手順でレンダリングを行い、マスクエリアを特定します。

マスクエリアを定義する「マスク用シェーダ」

これは単純に、マスクオブジェクトの領域を示すStencil値を書き込み、あとで範囲を限定するために利用します。
コードはとてもシンプルです。

Shader "Custom/Mask" {
    Properties {
        //
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent+10" }

        LOD 200

        Pass
        {
            Stencil
            {
                Ref 10
                Comp Always
                Pass Replace
            }

            Cull Front
            ZWrite On
            ZTest LEqual
            ColorMask 0

            CGPROGRAM

            #include "Assets/Mask/Utility/Mask.cginc"
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0

            float4 frag(v2f i) : SV_Target
            {
                return half4(0, 1, 0, 1);
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

見ての通り、ColorMask 0にしてマスクエリアにStencil値と深度値を書き込んでいるのみです。
ただ注意点はCull Frontを指定してマスクエリアの「内側」をレンダリングしている点です。

ターゲットをマスクする「ターゲット用シェーダ」

次に、ターゲットとなるオブジェクトを数回レンダリングし、表面と背面をそれぞれ描き分けてマスクを生成します。

f:id:edo_m18:20170307100656p:plain

それぞれマスクをかけて、Viewerで色を表示したところ。
(Viewerはそれ用のシェーダを書いて(後述)、Stencilの値によって色を出力するPlaneを配置しているだけです)

赤い部分が一番最初にマスクを掛けた部分。(つまりマスク対象エリア)
青い部分が、マスクオブジェクトの「内側」に存在するターゲットオブジェクトの部分。
緑色の部分が、ちょうどマスクオブジェクトと交差しているクリップ面(断面)。
そして(やや見づらいですが)グレーの部分が、マスクオブジェクト外だけれどカメラの視点から見るとマスクオブジェクトの向こう側(つまり深度テストに合格していないところ)となります。

このシェーダは全部で3passを利用してマスクを生成しています。
(すべてのpassは、最初のマスクエリアとしてマークした中でのみ処理を行うようにしています)

1pass目

Pass
{
    Stencil
    {
        Ref 10
        Comp Equal
        ZFail IncrSat
    }

    Cull Back
    Zwrite Off
    ZTest GEqual
    ColorMask 0

    CGPROGRAM

    #include "Assets/Mask/Utility/Mask.cginc"
    #pragma vertex vert
    #pragma fragment frag
    #pragma target 3.0


    float4 frag(v2f i) : SV_Target
    {
        return half4(1, 0, 0, 1);
    }

    ENDCG
}

1pass目は、ターゲットオブジェクトの「表面」を、ZTest GEqualレンダリングします。
加えてStencilはZFail IncrSatを指定します。

最初にマスクエリアとしてマークした部分の中で、ZTestを反転した上で、さらにそれがFailした部分にのみマークをつけています。
上でも書いた通り、マスクエリアのレンダリングは、マスクエリアの「内側」をレンダリングしたものでした。

つまり、結果として「マスクエリア内」にある領域に、ステンシルバッファが書き込まれることになります。

f:id:edo_m18:20170307131105p:plain

1pass目までをレンダリングした結果のStencilの状態を表示したところ。青い部分が該当箇所。

2pass目

Pass
{

    Stencil
    {
        Ref 11
        Comp Equal
        ZFail IncrSat
    }

    Cull Front
    Zwrite Off
    ZTest LEqual
    ColorMask 0

    CGPROGRAM

    #include "Assets/Mask/Utility/Mask.cginc"
    #pragma vertex vert
    #pragma fragment frag
    #pragma target 3.0


    float4 frag(v2f i) : SV_Target
    {
        return half4(1, 0, 0, 1);
    }

    ENDCG
}

続いて2passは、1pass目で増加したステンシル値と比較し、同じ箇所に対してレンダリングを行います。
ただ2pass目はZTestを元に戻し、かつCull Frontにしてレンダリングを行い、さらにその上でZFailしたところにマークを付けます。
要は、ターゲットオブジェクトが描かれるべき場所に対して裏側をレンダリングし、かつ「背面」となる部分にマークを付けるわけです。

f:id:edo_m18:20170307131529p:plain

それを実行して、各ステンシルの値ごとに色を塗ると上記のようになります。
この時点で、ターゲットオブジェクトの「背面」に対してマスクが生成されているのが分かるかと思います。

3pass目

Pass
{
    Stencil
    {
        Ref 10
        Comp Equal
        ZFail DecrSat
    }

    Cull Back
    Zwrite Off
    ZTest LEqual
    ColorMask 0

    CGPROGRAM

    #include "Assets/Mask/Utility/Mask.cginc"
    #pragma vertex vert
    #pragma fragment frag
    #pragma target 3.0


    float4 frag(v2f i) : SV_Target
    {
        return half4(1, 0, 0, 1);
    }

    ENDCG
}

そして最後の3pass目。
最後はまた、Stencilの値が最初にマスクした部分に対して実行し、Cull BackかつZTest LEqualレンダリングします。(つまり普通のレンダリング

その際、これまたZFailした箇所に対して、今度はステンシルの値を減らします。
ここまでを実行すると、最終的には以下のようなマスクの状況になります。

f:id:edo_m18:20170307131852p:plain

最後に、3passを含んだすべてのシェーダコードを載せておきます。

Shader "Custom/TargetMask" {
    Properties
    {
        //
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent+11" }

        LOD 200

        // --------------------------------------------------
        Pass
        {
            Stencil
            {
                Ref 10
                Comp Equal
                ZFail IncrSat
            }

            Cull Back
            Zwrite Off
            ZTest GEqual
            ColorMask 0

            CGPROGRAM

            #include "Assets/Mask/Utility/Mask.cginc"
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0


            float4 frag(v2f i) : SV_Target
            {
                return half4(1, 0, 0, 1);
            }

            ENDCG
        }


        // --------------------------------------------------
        Pass
        {

            Stencil
            {
                Ref 11
                Comp Equal
                ZFail IncrSat
            }

            Cull Front
            Zwrite Off
            ZTest LEqual
            ColorMask 0

            CGPROGRAM

            #include "Assets/Mask/Utility/Mask.cginc"
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0


            float4 frag(v2f i) : SV_Target
            {
                return half4(1, 0, 0, 1);
            }

            ENDCG
        }


        // --------------------------------------------------
        Pass
        {
            Stencil
            {
                Ref 10
                Comp Equal
                ZFail DecrSat
            }

            Cull Back
            Zwrite Off
            ZTest LEqual
            ColorMask 0

            CGPROGRAM

            #include "Assets/Mask/Utility/Mask.cginc"
            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0


            float4 frag(v2f i) : SV_Target
            {
                return half4(1, 0, 0, 1);
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

マスクエリアをレンダリングする「マスク用レンダリングシェーダ」

さて、以上で今回のサンプルで利用するマスク情報が手に入りました。次は、実際にマスクエリアをレンダリング(カラー出力)し、見た目を構築していきます。

1pass目

Pass
{
    Stencil
    {
        Ref 12
        Comp Equal
    }

    CGPROGRAM

    #pragma vertex vert
    #pragma fragment frag

    float4 frag(v2f i) : SV_Target
    {
        return _MaskColor;
    }

    ENDCG
}

1pass目はとてもシンプルです。
Stencil値が12のエリアに対してレンダリングを行います。
Stencil値が12の箇所は「ターゲットオブジェクトの背面」部分です。

なので、ここはクリップされた断面を出力するパスになります。
これを実行すると以下のようになります。

f:id:edo_m18:20170307132605p:plain

まだ他のパスを描いていないので若干分かりづらいかもしれませんが、断面にだけ、指定した色が塗られているのが分かるかと思います。

2pass目

Pass
{
    Blend SrcAlpha OneMinusSrcAlpha
    Cull Back
    
    CGPROGRAM

    #pragma vertex vert
    #pragma fragment frag
    #pragma target 3.0

    float4 frag(v2f i) : SV_Target
    {
        return half4(0, 0.5, 1.0, 0.0);
    }

    ENDCG
}

2pass目は、マスクオブジェクトの「表面」をレンダリングします。
ただレンダリングといっても完全透明になるようにレンダリングするため、ぱっと見はなにが起きたか分からないかもしれません。
(そして同時に、深度値も更新しています。というか、深度値を更新することが主な目的)

それを実行すると、以下のように、ターゲットオブジェクトがマスクオブジェクトの背面に隠れて消えるのが分かるかと思います。

f:id:edo_m18:20170307132741p:plain

3pass目

Pass
{
    Stencil
    {
        Ref 9
        Comp Equal
        Pass Keep
    }

    Blend SrcAlpha OneMinusSrcAlpha
    Cull Back
    ZTest Always
    
    CGPROGRAM

    #pragma vertex vert
    #pragma fragment frag
    #pragma target 3.0

    FragOut frag(v2f i)
    {
        FragOut o = (FragOut)0;
        o.color = half4(0.0, 0.5, 1.0, 0.0);
        o.depth = 1 - UNITY_NEAR_CLIP_VALUE;
        // #if UNITY_REVERSED_Z
        // o.depth = 0;
        // #else
        // o.depth = 1;
        // #endif
        return o;
    }

    ENDCG
}

最後の3pass目です。
ここは少しだけ特殊な処理が入っています。

まず、このパスに関してはZTest Alwaysレンダリングを行います。
かつ、ステンシルの値は、ターゲットオブジェクトでマスクのデータを収集した際に「減算」した部分のみに行います。

そしてその箇所の「深度値をクリア」します。
該当コードは以下の部分です。

o.depth = 1 - UNITY_NEAR_CLIP_VALUE;

この後の補足で書きますが、プラットフォームごとに深度値の扱いが変わるため、それを考慮した記述になっています。
やっていることはシンプルに、深度値を一番遠い部分(つまりまだなにも描かれてない状態)に初期化します。

なぜそうするかというと、2pass目で深度値を描いてしまっているがために、ターゲットとなるオブジェクトが透明なマスク領域に「隠れて」しまうため、そこの部分をくり抜くために実行しているわけです。

それを踏まえて実行すると、以下のように冒頭のアニメーションGifと同じ見た目になるのが確認できます。

f:id:edo_m18:20170307133025p:plain

ターゲットとなるオブジェクトは、最終的なパスで普通にオブジェクトをレンダリングしてやれば無事、マスク領域でクリップされた断面が描かれるようになる、というわけです。

このシェーダの全文も以下に載せておきます。

Shader "Custom/MaskRender" {
    Properties {
        _Color ("Color", Color) = (1,1,1,1)
        _MaskColor ("Mask color", Color) = (0.0, 0.9, 1.0, 1.0)
        _MainTex ("Albedo (RGB)", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Transparent" "Queue"="Transparent+12" }

        LOD 200


        // --------------------------
        CGINCLUDE

        sampler2D _MainTex;
        sampler2D _MaskGrabTexture;

        struct appdata
        {
            float4 vertex : POSITION;
            float3 normal : NORMAL;
        };

        struct v2f
        {
            float4 pos : SV_POSITION;
            float3 normal : TEXCOORD1;
            float4 uvgrab : TEXCOORD2;
        };

        struct FragOut
        {
            float4 color : SV_Target;
            float depth : SV_Depth;
        };

        fixed4 _Color;
        fixed4 _MaskColor;

        v2f vert(appdata i)
        {
            v2f o;
            o.pos = mul(UNITY_MATRIX_MVP, i.vertex);

            #if UNITY_UV_STARTS_AT_TOP
            float scale = -1.0;
            #else
            float scale = 1.0;
            #endif

            // Compute screen pos to UV.
            o.uvgrab.xy = (float2(o.pos.x, o.pos.y * scale) + o.pos.w) * 0.5;
            o.uvgrab.zw = o.pos.zw;

            return o;
        }

        ENDCG
        // --------------------------


        // ------------------------------------------------------
        // Target back face.
        Pass
        {
            Stencil
            {
                Ref 12
                Comp Equal
            }

            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            float4 frag(v2f i) : SV_Target
            {
                return _MaskColor;
            }

            ENDCG
        }


        // ------------------------------------------------------
        Pass
        {
            Blend SrcAlpha OneMinusSrcAlpha
            Cull Back
            
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0

            float4 frag(v2f i) : SV_Target
            {
                return half4(0, 0.5, 1.0, 0.0);
            }

            ENDCG
        }


        // ------------------------------------------------------
        Pass
        {
            Stencil
            {
                Ref 9
                Comp Equal
                Pass Keep
            }

            Blend SrcAlpha OneMinusSrcAlpha
            Cull Back
            ZTest Always
            
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag
            #pragma target 3.0

            FragOut frag(v2f i)
            {
                FragOut o = (FragOut)0;
                o.color = half4(0.0, 0.5, 1.0, 0.0);
                o.depth = 1 - UNITY_NEAR_CLIP_VALUE;
//              #if UNITY_REVERSED_Z
//              o.depth = 0;
//              #else
//              o.depth = 1;
//              #endif
                return o;
            }

            ENDCG
        }
    }
    FallBack "Diffuse"
}

番外編 - Stencil Viewer

解説で書いていた「Stencil Viewer」ですが、こちらの記事(Unity 4.2 - Stencils for portal rendering)で紹介されているコードを読んでいたときに知ったものです。

といっても大した内容ではなく、以下のように、できるだけレンダリング順を後に回し、該当のステンシル値が書き込まれたエリアに対して「確実に」色を出力するシェーダを書きます。

Tags { "RenderType"="Opaque" "Queue"="Transparent+500"}      

ZWrite Off
ZTest Always

fixed4 _Color0;

Pass
{
    Stencil 
    {
        Ref 9
        Comp Equal
        Pass Keep
    }

    CGPROGRAM

    #pragma vertex vert
    #pragma fragment frag

    half4 frag(v2f i) : COLOR 
    {
        return _Color0;
    }

    ENDCG
}

まず、Tags"Queue"="Transparent+500"を追加して、レンダリング順を透明オブジェクトよりもさらに後に回します。
その上で、ZTest Alwaysに変更して、深度テストを無視してステンシルのみでレンダリングが判断されるようにします。

あとはステンシルの参照値を設定してそこに対して色を出力すれば、冒頭の説明のキャプチャのように、ステンシル値ごとに塗り分けることができる、というわけです。
(複数のステンシル値の色を出力したい場合は上記のPassを複数記述して、それぞれ参照するステンシル値を変更してあげればOKです)

出会った問題点

深度

プラットフォームごとの深度値の扱い

今回のサンプルでは、上の深度テストでの問題でも触れたように、オブジェクトごとの重なり以外にもカメラからの視点方向によるマスキングなどが行われ、色々と試行錯誤しました。
最終的には深度値をシェーダ側でクリアすることによって対策したのですが、その際に知った点として、バッファで使われている深度値がプラットフォーム(OpenGLDirectXPS4など)ごとに異なる、という点でした。

以下のUnityのドキュメントにも記載があり、またそれぞれのプラットフォーム向けに適切にコンパイルされるようマクロなども用意されています。

https://docs.unity3d.com/Manual/SL-PlatformDifferences.htmldocs.unity3d.com

サンプルでは、マスクした上でマスク対象以外のDepthをクリアしています。該当コードを抜き出すと以下。

FragOut frag(v2f i)
{
    FragOut o = (FragOut)0;
    o.color = half4(0.0, 0.5, 1.0, 0.0);
    o.depth = 1 - UNITY_NEAR_CLIP_VALUE;
    // 下の書き方でも多分大丈夫
    // #if UNITY_REVERSED_Z
    // o.depth = 0;
    // #else
    // o.depth = 1;
    // #endif
    return o;
}

Unity側でどちらのタイプか、あるいはNear Clip面の値を持つdefineがあるので、それを利用して適切に値を設定しています。
(やっていることは、該当の箇所のDepthを、なにも描かれていない状態(=一番遠い値)にしています)

Tagsの「Queue」はひとつのシェーダ内でひとつのみ有効

最初、TagsQueueでレンダーキューをいじってひとつのシェーダ内でごにょごにょしようとしていたら、QueueSubShaderにのみ適用されて、Passには影響しないことを知りませんでした。(Passそれぞれに影響するTagsもあります)
ただ、Pass自体は書かれた順に実行されるので、Queueの中でレンダリング順を制御する場合にはPassの順番を入れ変えることで順番を制御することができます。
@hecomiさんに指摘されて追記しました)

調べてみたら以下の投稿同じ質問をしている人がいました。

answers.unity3d.com

そこでの回答が以下。

The reason the code above doesn't work is that "tags" apply only to subshaders, whereas culling/depth testing options apply to passes.

参考にした記事

*1:今回作成したシェーダ名

Photonを使ってネットワーク同期させる

概要

今作っているコンテンツはネットワークの同期を行って遊ぶゲームを想定していて、プラットフォームにPhotonを選びました。

ネットワーク同期をする場合、サーバ・クライアント型のものとP2P型のものとがあり、PhotonはP2P型のものになります。
UNETもPhotonもUnity + C#だけで完結する形になっているので、サーバ側のコードもほぼ同じ感覚で(そして同じ位置に)記述することができます。
(Photonの場合はマスタークライアントとそれ以外での動作)

一方で、それが故に「これはどっち側のコード?」というのが混乱したりします。
また、「オーナーシップ」や「権限」など、ひとりを対象としたゲームでは必要のない概念も出てきて、最初はそれらを理解するのに多少時間をようすると思います。
ということで、ネットワーク同期に対してのあれこれをメモしておこうと思います。

PhotonNetworkの接続フロー

まず、PhotonNetworkを利用する場合、以下の手順に従ってマスタークライアントとコネクションを張る必要があります。
(もし「ルーム」が存在しない場合は自分自身が「マスタークライアント」になって「ルーム」を作成する必要があります)

  1. Photonサーバに接続(ルームの検索など、入り口)
  2. マスタークライアントに接続
  3. いずれかの「ルーム」に接続

まず、Photonのサーバに接続し、現在接続されているMasterClientに接続します。
その後、作成されているルームにJoinします。
このとき、マスターがまだ存在しない場合は自身がマスターになるようにルームを作成します。
もしマスターが存在し、かつルームがあればそれにJoinするようにします。
(もちろん、ルームを選んでJoinすることも可能です)

コードにすると以下のように、MonoBehaviourのようにコールバックが呼ばれる仕組みになっているので、それを使って処理を行います。

public class PhotonManager : Photon.PunBehaviour
{
    public string ObjectName;

    void Start()
    {
        // Photonネットワークの設定を行う
        PhotonNetwork.ConnectUsingSettings("0.1");
        PhotonNetwork.sendRate = 30;
    }

    // 「ロビー」に接続した際に呼ばれるコールバック
    public override void OnJoinedLobby()
    {
        Debug.Log("OnJoinedLobby");
        PhotonNetwork.JoinRandomRoom();
    }

    // いずれかの「ルーム」への接続に失敗した際のコールバック
    void OnPhotonRandomJoinFailed()
    {
        Debug.Log("OnPhotonRandomJoinFailed");

        // ルームを作成(今回の実装では、失敗=マスタークライアントなし、として「ルーム」を作成)
        PhotonNetwork.CreateRoom(null);
    }

    // Photonサーバに接続した際のコールバック
    public override void OnConnectedToPhoton()
    {
        Debug.Log("OnConnectedToPhoton");
    }

    // マスタークライアントに接続した際のコールバック
    public override void OnConnectedToMaster()
    {
        Debug.Log("OnConnectedToMaster");
        PhotonNetwork.JoinRandomRoom();
    }

    // いずれかの「ルーム」に接続した際のコールバック
    public override void OnJoinedRoom()
    {
        Debug.Log("OnJoinedRoom");

        // 「ルーム」に接続したらCubeを生成する(動作確認用)
        GameObject cube = PhotonNetwork.Instantiate(ObjectName, Vector3.zero, Quaternion.identity, 0);
    }

    // 現在の接続状況を表示(デバッグ目的)
    void OnGUI()
    {
        GUILayout.Label(PhotonNetwork.connectionStateDetailed.ToString());
    }
}

実際に実行してみると以下のようにログが出力され、部屋にJoinするまでの流れが分かります。
f:id:edo_m18:20170219234438p:plain

手順としては以下のようになります。

  1. Photonサーバに接続
  2. マスタークライアントに接続
  3. (例では)ランダムなルームに接続を試みる
  4. (例では)接続に失敗して、新しくルームを作成する
  5. ルームにJoin

以上の記述をした上で、複数のエディタかビルドしたアプリを起動すると、無事ふたつのCubeが画面に表示されるようになります。
(最初に起動したほうがマスタークライアントとなります)

ネットワーク同期するオブジェクトを生成する

さて、無事にネットワークで接続されました。
が、接続されただけでは自動的に同期は取ってくれません。
これは当然で、シーンにあるものすべてを同期していては処理が追いつかなくなってしまいます。

そこで、同期対象のオブジェクトや処理を指定し、「同期したいもの」のみを同期する必要があります。

同期させたいオブジェクトがすでにシーンにある場合は同期対象として指定するだけですが、新規でオブジェクトを生成する場合、「生成されたこと」も同期しないとなりません。
(しないと自分のほうでは見えていて相手には見えない、ということになってしまいます)

そのためには以下のメソッドを使ってオブジェクトを生成してやる必要があります。

// in PhotonNetwork class
public static GameObject Instantiate(string prefabName, Vector3 position, Quaternion rotation, int group);

これは、いつも使っているGameObject.Instantiateとは異なり、ネットワーク同期対象となるオブジェクトの生成です。
なのでメソッドはPhotonNetworkクラスに、静的メソッドとして定義されています。

基本的な引数は似ていますが、第一引数にPrefab名をstringで渡す点と、最後の引数にgroupを指定する点が異なります。

※ ちなみに、第一引数で指定するPrefab名は、Resourcesフォルダに入っているオブジェクトに限定されます。(ただし、どの階層でも大丈夫なようです。e.g. /Assets/Hoge/ResourcesでもOK)

なお、生成対象のPrefabはPhotonViewコンポーネントがアタッチされている必要がある点にも注意です。

生成した同期オブジェクトを特定する

基本的には、上記のメソッドで生成した時点で接続されている全クライアントでオブジェクトが生成されます。
しかし、クライアント側で、同期対象として生成されたオブジェクトをなにがしか特定して処理したい場合があります。
(例えば、生成したオブジェクトの見た目をローカルでだけ変えたい場合など)

その場合は、マスタークライアントから各クライアントに通知を行い、その通知の中でviewIDを送り、それを元にオブジェクトを特定する必要があります。
(「通知(RPC)」については後述)

PhotonView PhotonView.Find(int viewID);

PhotonViewクラスのFindメソッドに、int型のviewIDを渡すことで、該当のView IDを持つオブジェクトが返されます。
あとはこれを用いて色々と必要な処理を行います。

変数をネットワーク同期する

変数の同期を行うにはPhoton.MonoBehaviourクラスかPhoton.PunBehaviourクラスを継承し、void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)メソッドを実装する必要があります。
そしてそのメソッド内でstream.isWritingフラグによる分岐を行います。
ちなみにtrueである場合は「同期しようと」している方で、falseの場合は「同期されようと」している方の処理となります。

void OnPhotonSerializeView(PhotonStream stream, PhotonMessageInfo info)
{
    if (stream.isWriting) { /* 書き込み処理 */ }
    else { /* 読み込み処理 */ }
}

また、書き込みたい場合はstream.SendNextメソッドに同期したい値を指定し、読み込み側ではstream.ReceiveNextメソッドにより受け取ります。
このメソッドはシリアライズした情報を送るメソッドのため、常に送信側と受信側で読み込む順番をしっかりと担保しなければなりません。(そもそもどの値を取り出すか、は指定できないので無理ですが。順番に取り出すしかない)

// private float _hoge = 3f;
// private int _fuga = 5;

if (stream.isWriting) {
    stream.SendNext(_hoge);
    stream.SendNext(_fuga);
}
else {
    _hoge = (float)stream.ReceiveNext();
    _fuga = (int)stream.ReceiveNext();
}

ちなみに、マスタークライアントしか存在しない場合はそもそも通知相手がいないため、このメソッドは実行されません。
同期のテストをしたい場合は必ず別クライアントを接続して、少なくともふたり以上の状態でないと機能しないので注意してください。

RPCで他クライアントのメソッドを実行する

同期対象のオブジェクトを生成する箇所でも触れた通り、別のクライアントに対してなにかしらのメッセージを送りたい場合があります。
その際に利用されるのがこの「RPC(Remote Procedure Call)」です。
名称からも分かるように、リモートクライアントに対して関数を実行する命令を送るための仕組みです。
(RPC自体はUnityだけのものでも、Photonだけのものでもない標準的な機能です)

RPCを送る場合はまず、RPCで実行したいメソッドにPunRPCAttributeをつけ目印を付けます。

[PunRPC]
void AnyMethod()
{
    // do something.
}

こうすることで、このメソッドがRPC対応であることをシステムに伝えることができます。
その後、RPCを実行したいタイミングで、以下のようにPhotonViewRPCメソッドを実行します。

// RPCの呼び出しは文字列で以下のように実行する。(第三引数以降(可変引数)を渡すと、引数ありのメソッドも呼べる)
_photonView.RPC("AnyMethod", PhotonTargets.All);

すると、第一引数で指定した文字列のメソッドを、第二引数で指定した対象で実行することができます。(例では全クライアント)

RPCのターゲット

第二引数で指定できるターゲット対象は以下の通りです。

  • PhotonTargets.All
  • PhotonTargets.Others
  • PhotonTargets.MasterClient
  • PhotonTargets.AllBuffered
  • PhotonTargets.OthersBuffered
  • PhotonTargets.AllViaServer
  • PhotonTargets.AllBufferedViaServer

各項目の意味はドキュメントを参照ください。

とにかく全体に対してRPCを贈りたい場合はAll、自身の実行は終えていて結果を各クライアントに送りたい場合はOthers、マスターに対してなにか処理を通知したい場合はMasterClientを使う感じになると思います。

LocalPlayer

UNETなど、おそらくネットワークのマルチプレイでのアーキテクチャなんだと思いますが、「ローカルプレイヤー」という概念が存在します。 マルチプレイということは、複数のプレイヤーが同時に存在している状態です。 当然、ゲーム画面にはたくさんの「ユーザが操作する」キャラクターが映し出されていることでしょう。

通常のひとりプレイのゲームであれば、NPCか自分しかいないため、ゲーム内にあるオブジェクトに対して「人が」干渉できるのは常にひとつのキャラクターしかありません。 しかし、マルチプレイの場合は他人とはいえ一般のユーザ、つまり「人が」操作しているキャラクターたちです。

例えばサッカーのオンライン対戦ゲームがあった場合、全員でボールを追いかけることになりますが、ではこのボールを「動かすことができる」のは誰になるのでしょうか。 すべてを物理挙動にまかせているものであれば、たんに移動したキャラクターがぶつかっただけ、という結果のみでゲームを進行することができますが、通常のゲームではこれはむずかしいでしょう。

ボールに対して「蹴る」などのアクションを起こしたり、多少の、物理挙動から離れる動かし方をしたいことがほとんどだと思います。 そうした場合に、みながみな、一斉にボールに対してアクションを実行してしまっては成り立つものも成り立ちません。

ではどうするのか。 それが「ローカルプレイヤー」という概念と、権限(やオーナーシップ)という概念です。 ローカルプレイヤーとは、今まさに実行されているゲームをプレイしているユーザただひとりだけが設定される設定です。 (当然、遠隔地の別のPCでは別のキャラクターが「ローカルプレイヤー」として設定される)

そしてたくさんいるローカルプレイヤーのうち、誰がボールに対するアクションが行えるのか、を決めるのが「権限」というわけです。 つまり、(上の例で言えば)権限を持ったローカルプレイヤーだけがボールを操作することができる、というわけです。

以下、そうした「プレイヤー」などを取得するためのメソッドです。

PhotonNetowrk.player;
PhotonNetwork.masterClient;
PhotonNetwork.playerList;
PhotonNetwork.otherPlayers;
PhotonNetwork.isMasterClient;

Ownershipをリクエストする

前述のように、Photonには「オーナーシップ」という概念があります。
オーナーシップ(権限)を持っているクライアントだけが、そのオブジェクトに対して操作を実行することができます。

しかし、起動してからずっと、ひとつのクライアントがオーナーシップを持ち続けていると都合が悪い場合があります。
例えば、簡単なアクションゲームの場合を考えてみると、とあるクライアントが落とした武器を、別のクライアントが持ったとしましょう。

そのとき、もしこのオーナーシップが固定だった場合、武器を捨てたクライアントがオーナーシップを持ち続けることになります。
結果、そのクライアントの操作が有効になる、つまり「捨てた状態が生き続ける」ということになります。

要は武器を持っても、「落ちている」という状況が継続してしまうわけですね。
しかし、それでは都合が悪いのはすぐ分かると思います。

そういうときに行うのがこの「オーナーシップの権限委譲リクエスト」です。
リクエストを実行すると、「現在オーナーシップを持っているクライアントに」リクエスト処理が通知されます。

そしてそのクライアント上で処理を行い、権限委譲を許可すると晴れて、リクエストを送った側にオーナーシップが渡ってきます。

ということで、リクエスト処理と権限委譲の処理は以下のようになります。

_photonView.RequestOwnership();

まずは対象に対して、オーナーシップを移譲してくれるようリクエストします。

public override void OnOwnershipRequest(object[] viewAndPlayer)
{
    PhotonView view = viewAndPlayer[0] as PhotonView;
    PhotonPlayer requestingPlayer = viewAndPlayer[1] as PhotonPlayer;

    Debug.Log("OnOwnershipRequest(): Player " + requestingPlayer + " requests ownership of: " + view + ".");
    
    view.TransferOwnership(requestingPlayer.ID);
}

すると、オーナーシップを持つクライアント側で上記コールバックが呼び出されます。
ここで対象クライアントなどをチェックし、(また必要であれば自身の状態をチェックし)許可する場合はTransferOwnershipメソッドを実行します。

実行後、引数に指定したプレイヤー(クライアント)にオーナーシップが移譲されます。

シーンに配置しているオブジェクトのオーナーシップは?

最初軽くハマった部分ですが、最初からシーンに配置してあるオブジェクトのオーナーシップは(おそらく)マスタークライアントのものになります。
そして、デフォルトではオーナーシップは「固定(Fixed)」に設定されています。

つまり、いくらリクエストしてもコールバックが呼び出されず、オーナーシップを移譲することができません。
ただ設定は簡単で、PhotonViewコンポーネントのオーナーシップ権限の状態をどうするかを設定する項目がインスペクタ上にあるため、これをRequestに変更することで移譲が可能となります。

Photonの設定メモ

Photonの同期間隔などは以下のようにして設定することができます。

PhotonNetwork.ConnectUsingSettings("0.1");
PhotonNetwork.sendRate = 30;
PhotonNetwork.sendRateOnSerialize = 30;

VR内でつかんだオブジェクトをイメージ通りに投げる

概要

今回の記事は、この記事を熟読して実装したものになります。

www.gamasutra.com

今作っているVRコンテンツは、「VRコンテンツ内で誰しもが共通してやることは、掴んだものは必ず投げる」というところに着目して、「VRで投げる」をコンセプトに開発を進めています。
色々なコンテンツを作ったりやったり見てみたりしていますが、ほぼ確実に、VR空間内の物を掴むとそれを投げます。
いい大人が、まるで子どもに戻ったかのようにひたすら物を投げる。

そしてコンテンツ体験が終わったあとは子どもがやったのかと思うほどに色々な物を投げ散らかした状態になる、という始末。

でもこれって、きっと子どもも大人も「人間」という部分で見たら違いがないことの証明なのかもしれません。大人は分別があるからやらないだけで、深層心理では子どもと変わらない、という。

ということで、ひたすら投げる部分を追求したコンテンツなわけです。
ちなみに別の視点では、物を投げる際、「思ったほど飛ばない」とびっくりするほどストレスが溜まります。
一方、思った以上に飛ぶのはかなり気持ちいいです。どちらも「思ったより」という状況なのにこの違い。

ということで、今作っているコンテンツは投げるだけでなく、投げる力を増幅して誰でもイチローの遠投のように、あるいはベジータドラゴンボールを投げたときのように、ひたすら物を速く、遠くに投げれる、というところを意識して開発しています。

そんなコンテンツなので当然、物を投げる際の挙動はしっかりと作りこまないとなりません。
そして参考にしたのが冒頭の記事、というわけです。

ざっくりフロー

簡単に今回実装したことを列挙すると以下のような感じになります。

  • 物を持っている間、常に最新の10フレーム分の速度ベクトルをサンプルし続ける(つまりデータとしては10個の配列)
  • 投げる動作を検知した際に、最後のフレームから見て90度以上開いているベクトル(つまり後ろ向きのベクトル)は除外する
  • さらに、残ったベクトルから偏差値を求め、一定の偏差値を持つものを信頼するベクトルとして採用する
  • そしてさらに「ローパスフィルタ」を用いて、ベクトルを滑らかにしたグラフを求める
  • 最後に、フィルタリングして残ったベクトルデータを使って「最小二乗平面」を求め、最後のベクトルをその平面に射影したベクトルを、最終的な投げるベクトルとする

という感じで実装しました。
まぁやっていること自体は数学の基本的なところを押さえつつ、みたいな感じで実装しています。
ただおかげでだいぶ数学的なロジックをプログラムに落とす、というところがよりイメージしやすくなりました。

(記事を元に)他に意識した点

参考にした記事には、オブジェクトの重心の話と、投げるときのトリガーの強さの話などが載っていました。
具体的にどういうことかというと。

重心を意識する

プログラムで書いているとついつい、「視覚的に持っているオブジェクト」の位置を元に速度を求めてしまいます。
しかし、プレイヤー(ユーザ)はコントローラを握っているのであって、VR空間内のオブジェクトを実際に持っているわけではありません。

つまりそこに、視覚と、筋肉が認識している重心にずれが生じている、ということです。

冒頭の記事から図を引用させてもらうと以下の場所にコントローラの重心があります。 f:id:edo_m18:20170217111540p:plain

なので、記事ではオブジェクトではなく、あくまでコントローラの重心を採用しろ、と書いてありました。
ただ幸いにして(?)、UnityのViveプラグインが提供してくれているコントローラのUnity上の位置はちょうどその重心が中心になるようになっていました。
なので、今回の実装ではその箇所の移動差分を取って速度としてサンプリングしています。

ユーザが握っているトリガーの強さは一定ではない

Viveのコントローラの場合は「カチッ」となるまで握ると、数値的には1になるので基本は1のままだと思いますが、ユーザが「ここからは物を投げている」という認識になるには多少のゆらぎがあるようです。
これもまた冒頭の記事から図を引用させてもらうと以下のようになるようです。

f:id:edo_m18:20170217111817p:plain

※ 今回の実装では色々試したところ、普通にトリガーを握っているか、のフラグを見るだけでイメージ通りになったのでここについては保留にしてあります。

使った数式や理論など

今回の実装は、かなりの部分で数学的な要素が多いものとなりました。
実装で使った数学的な内容は以下の通りです。

  • 偏差値
  • 標準偏差
  • ローパスフィルタ
  • 最小二乗平面

偏差値

偏差値。もっともよく聞くのは学力での偏差値だと思います。
Wikipediaから引用すると以下の意味になります。

偏差値(へんさち、英: standard score)とは、ある数値がサンプルの中でどれくらいの位置にいるかを表した無次元数。平均値が50、標準偏差が10となるように標本変数を規格化したものである。

要は、点数という絶対値や相対値(平均値)だけでは、その人の学力が全体的に見てどれくらいか、が判断しづらいから偏差(ばらつき)を取って確かな指標としましょう、というようなことですね。

ちなみに今回の実装では、サンプリングした速度データ全体から「より優秀な」値を示しているものを採用する目的で「偏差値」を利用しました。
ここでの「優秀な値」というのは、投げるときは速度が速い、ということを利用して「より速いと思われるデータ」をフィルタリングする目的です。

単純に「一定値以上の」としてしまうと、投げるスピードがまちまちなので「投げたことにならない」場合があったり、あるいはすべてのデータがあまりにも速すぎるとそもそもすべてのデータが採用条件を満たしてしまう、というのを避けるために「全体のデータの中で特に優秀なもの」というのを抽出するために採用しました。

偏差値の求め方は以下のようになります。
偏差値の求め方を参考にしました)

  1. 標準偏差を求める
  2. 平均値との差の絶対値に10をかけ、標準偏差で割る
  3. サンプリングした値が平均値より高ければ(2)で求めた値を50に足す、低い場合は50からその値を引く。それを「偏差値」とする

という具合です。 そして今回は偏差値60以上の値のみを利用することにしました。

標準偏差

偏差値を求める際に必要となる「標準偏差」。

標準偏差とは「データのばらつきの大きさ」を表す指標です。
記号は\(σ\)(シグマ)または\(s\)で表される数値です。 定義としては以下になります。

標準偏差は「各データの値と平均の差の二乗の合計を、データの個数で割った値の正の平方根」となります。 つまり、数式にすると以下。

\begin{align*} s = \sqrt{\frac{1}{n}\sum_{i=1}^{n} (x_i - \vec{x})^{2}} \end{align*}

  • s: 標準偏差
  • n: データの数
  • \(x_i\): 各データの値
  • \(\vec{x}\): データの平均

偏差値を求める際に必要なるため、標準偏差の計算を利用しています。

ローパスフィルタ

ローパスフィルタをWikipediaで調べると以下のように記載されています。

ローパスフィルタ(英語: Low-pass filter: LPF)とは、フィルタの一種で、なんらかの信号のうち、遮断周波数より低い周波数の成分はほとんど減衰させず、遮断周波数より高い周波数の成分を逓減させるフィルタである。ハイカットフィルタ等と呼ぶ場合もある。電気回路・電子回路では、フィルタ回路の一種である。

今回利用したのはノイズを軽減する目的で採用しました。
参考にした以下の記事から画像を引用させてもらうと

ehbtj.com

f:id:edo_m18:20170219095650p:plain

こんな感じで、ぎざぎざしているノイズ部分をうまく滑らかにしてくれます。
今回の実装では、手の動きによる入力のためこうしたノイズが発生してやたらと大きな数値が取られる、ということが何度かありました。
それを軽減する目的で利用しています。

最小二乗平面

最後に「最小二乗平面」。
最小二乗平面とは、標準偏差とも若干似た概念になりますが、全データから求める「とある平面」です。 その平面は、すべての点から、その平面に対しての距離の二乗が最小になる平面です。

最小二乗平面の求め方はQiitaのブログで書いたので、詳細はそちらをご覧ください。

qiita.com

今回の利用点としては、10フレーム分の速度データをサンプリングし、それを元に投げるときの速度を決定しています。
なので、この「最小二乗平面」を求め、「理想的な平面に対する速度データ」を算出することで、「人が思っている方向」になるべく近くなるように速度を計算しています。

実際のところこれがいい、というのは冒頭の記事に書かれていたのをそのまま利用しました。
が、実際に採用してみるとだいぶ思った方向に投げることができたので今回の記事を書くに至ったわけです。

まとめ

実際のところ、ここまでやってもまだ多少の違和感があったり、思ったように投げれない部分もあります。
が、最初に実装したものに比べたら格段によくなったのも事実です。

そしてなにより、思ったところに投げられるというのはそれだけで気持ちよさにつながるなーというのをより強く実感しました。

また今回の「投げる」部分以外でも、物をつかむ・離す、という部分もだいぶこだわって作ったので、だいぶ汎用的にVRで利用できるライブラリが完成したのも大きかったです。

ちなみに、今回のこの実装を利用したコンテンツを「JapanVR Fest.(旧オキュフェス)」で出展予定なので、興味がある方はぜひ遊びに来てください!

http://jvr-fest.com/2017/01/2894/

参考にした記事

MenuItemでVR Supportedを切り替える

概要

やってることはただのMenuItemのEditorスクリプトですが。
UNETなどネットワーク対応のコンテンツを作っていると、複数のエディタを立ち上げてネットワーク経由でのやり取りをデバッグしたくなることが多々あります。

(ちなみに、同一プロジェクトを複数エディタで機動する方法がテラシュールブログさんで紹介されています。これを使うとネットワーク対応のコンテンツのデバッグが捗ります)

tsubakit1.hateblo.jp

ただ、VRコンテンツを制作しているとたんにエディタで同一プロジェクトを起動しただけではうまく行きません。
というのも、HMDを利用できるのはひとつのアプリだけなので、あとから再生したエディタのほうにHMDが奪われてしまい、最初に再生していたエディタは強制的に再生がストップしてしまいます。

なので、Player SettingsのVirtual Reality Supportedのチェックをオフにすれば問題は解消されますが、そのオン・オフが地味にめんどい。
ということで、今回のスクリプトを書くに至ったわけです。

これをEditorフォルダにおけば、あとはToolsメニューからオン・オフが手軽に行えるようになります。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

[InitializeOnLoad]
public static class VirtualRealitySupportedMenu
{
    private const string MENU_NAME = "Tools/VirtualSupported";

    static VirtualRealitySupportedMenu()
    {
        EditorApplication.update += () =>
        {
            ImplToggleSupported(PlayerSettings.virtualRealitySupported);
        };
    }

    [MenuItem(MENU_NAME)]
    static void ToggleSupported()
    {
        ImplToggleSupported(!PlayerSettings.virtualRealitySupported);
    }

    static void ImplToggleSupported(bool enabled)
    {
        // Switch VR enable setting.
        PlayerSettings.virtualRealitySupported = enabled;

        // Set checkmark to menu item.
        Menu.SetChecked(MENU_NAME, enabled);
    }
}

VR空間内をデバッグできるようにマウスで移動する

概要

VR開発をしていると、HMDをかぶるのが意外と手間になります。
そこで、マウスで位置や回転を制御できると便利です。

ということで、カメラに適用しておくとさっと動かせるスクリプトを残しておきます。
こんな感じ↓

using UnityEngine;
using System.Collections;

/// <summary>
/// マウスでカメラの位置、回転を制御する
/// </summary>
public class MouseCameraController : MonoBehaviour
{
    enum DragType
    {
        Move,
        Rotate,
    }

    private bool _isDragging = false;
    private Vector3 _prevPos = Vector3.zero;
    private DragType _currenType;
    private float _speedLimit = 100f;
    private Quaternion _originalRot;

    private float _x = 0f;
    private float _y = 0f;

    [SerializeField][Range(0f, 10f)]
    private float _moveSpeed = 5f;

    [SerializeField][Range(0f, 10f)]
    private float _rotateSpeed = 5f;

    [SerializeField]
    private Transform _controlTarget;
    private Transform ControlTarget
    {
        get
        {
            if (!_controlTarget)
            {
                _controlTarget = transform;
            }

            return _controlTarget;
        }
    }


    #region MonoBehaviour
    void Start()
    {
        _originalRot = ControlTarget.rotation;
    }
    
    void Update()
    {
        float wheelval = Input.GetAxis("Mouse ScrollWheel");

        Vector3 pos = ControlTarget.position;
        pos += ControlTarget.forward * wheelval * 2f;
        ControlTarget.position = pos;

        if (Input.GetMouseButtonDown(0))
        {
            OnMouseDown(DragType.Move);
        }

        if (Input.GetMouseButtonDown(1))
        {
            OnMouseDown(DragType.Rotate);
        }

        if (Input.GetMouseButtonUp(0) || Input.GetMouseButtonUp(1))
        {
            OnMouseUp();
        }

        OnMouseMove();
    }
    #endregion


    void OnMouseDown(DragType type)
    {
        _isDragging = true;
        _currenType = type;
        _prevPos = Input.mousePosition;
    }

    void OnMouseUp()
    {
        _isDragging = false;
    }

    void OnMouseMove()
    {
        if (!_isDragging)
        {
            return;
        }

        Vector3 delta = Input.mousePosition - _prevPos;
        _prevPos = Input.mousePosition;

        switch (_currenType)
        {
            case DragType.Move:
                delta *= (_moveSpeed / _speedLimit);

                Vector3 pos = ControlTarget.position;
                pos += ControlTarget.up * -delta.y;
                pos += ControlTarget.right * delta.x;

                ControlTarget.position = pos;

                return;

            case DragType.Rotate:
                delta *= (_rotateSpeed / _speedLimit);

                _x += delta.x;
                if (_x <= -180)
                {
                    _x += 360;
                }
                else if (_x > 180)
                {
                    _x -= 360;
                }

                _y -= delta.y;
                _y = Mathf.Clamp(_y, -85f, 85f);

                ControlTarget.rotation = _originalRot * Quaternion.Euler(_y, _x, 0f);

                return;
        }

    }
}