e.blog

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

実行時に使えるユニークな識別子を生成する

概要

Unityで開発していると、ときに、「固定の」ユニークなID(識別子)を割り振りたいときがあります。
Unityにはもともと、Object.GetInstanceIDがありますが、これはユニークではあるものの、実行時や保存時などに書き換わる可能性があります。
(どのタイミングで変わるかはちゃんと調べてませんが、同じオブジェクトのIDが変わることがあるのを確認しています)

今回書くことは、各オブジェクトの固定IDです。
利用シーンとしては、オブジェクトごとのデータを保存してあとで復元する、などを想定しています。

具体例で言うと、シーンをまたいだときに、そのオブジェクトの位置を保存しておく、と言った用途です。

解決策

まず最初に、どうやって固定のIDを割り振るかを書いてしまうと、単純に、そのオブジェクトの「階層」を利用します。

つまり、OSのファイル管理と同様のことをやるってことですね。
各オブジェクトは必ず階層構造を持ち、シーンファイルに紐付いています。
つまり、シーンファイル名をルートとした階層構造を文字列化すれば、それはユニークなIDとなります。

一点だけ注意点として、OSであれば同名ファイル名は許されていませんが、Unityの場合は同階層に同じ名前のオブジェクトを配置することができます。
なので、少しだけ工夫して、最後に、Siblingのindexを付与することでユニークなIDができあがります。

最終的にはこのパスを元にした文字列のハッシュ値を保持して比較することで、ユニークなIDとして利用することができるようになります。

コード

コードで示すと以下のようになります。

// 以下は、「GameObjectUtility」クラスの静的メソッドとして定義していると想定。

/// <summary>
/// ヒエラルキーに応じたパスを取得する
/// </summary>
static public string GetHierarchyPath(GameObject target)
{
    string path = "";
    Transform current = target.transform;
    while (current != null)
    {
        // 同じ階層に同名のオブジェクトがある場合があるので、それを回避する
        int index = current.GetSiblingIndex();
        path = "/" + current.name + index + path;
        current = current.parent;
    }

    Scene belongScene = target.GetBelongsScene();

    return "/" + belongScene.name + path;
}

※ 上のコードのGetBelongsSceneは拡張メソッドで、以下のように実装しています。

using UnityEngine.SceneManagement;

public static class GameObjectExtension
{
    public static Scene GetBelongsScene(this GameObject target)
    {
        for (int i = 0; i < SceneManager.sceneCount; i++)
        {
            Scene scene = SceneManager.GetSceneAt(i);
            if (!scene.IsValid())
            {
                continue;
            }

            if (!scene.isLoaded)
            {
                continue;
            }
            
            GameObject[] roots = scene.GetRootGameObjects();
            foreach (var root in roots)
            {
                if (root == target.transform.root.gameObject)
                {
                    return scene;
                }
            }
        }

        return default(Scene);
    }
}

実際に使う際は、取得したパス文字列のハッシュを保持しておきます。

string id = GameObjectUtility.GetHierarchyPath(gameObject);
int hash = id.GetHashCode();

// hashをなにかしらで保存する

階層構造の変更に対応する

さて、上記までである程度固定のIDを振ることができますが、動的に、階層構造が変更される、あるいは子要素が追加されるなどは当然ながら発生します。

すると問題になるのが、実行順によって階層構造が変更され、完全なユニーク性が失われる、ということです。

なので、シーンファイルの保存タイミングをフックして、その際にSerializeFieldに保持してしまう、という方法でこれを解決します。

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

/// <summary>
/// 保存時に、シーンに設定されているユニークID保持対象のPathを設定(ベイク)する
/// </summary>
public class SaveAllUniquePath : UnityEditor.AssetModificationProcessor
{
    /// <summary>
    /// アセットが保存される直前のイベント
    /// </summary>
    /// <param name="paths">保存される対象アセットのパス</param>
    static private string[] OnWillSaveAssets(string[] paths)
    {
        foreach (var path in paths)
        {
            Scene scene = SceneManager.GetSceneByPath(path);

            if (scene.IsValid())
            {
                GameObject[] roots = scene.GetRootGameObjects();
                foreach (var root in roots)
                {
                    RecursiveUpdateUniquePath(root.transform);
                }
            }
        }

        return paths;
    }

    /// <summary>
    /// 再帰的にUniquePathを更新する
    /// </summary>
    static void RecursiveUpdateUniquePath(Transform target)
    {
        UniquePathTarget upt = target.GetComponent<UniquePathTarget>();
        if (upt != null)
        {
            upt.SetUniquePathAndHash();
        }

        for (int i = 0; i < target.childCount; i++)
        {
            RecursiveUpdateUniquePath(target.GetChild(i));
        }
    }
}

以上です。
まぁぶっちゃけベイクしてしまうので、そもそもシーン名と連番やインスタンスIDでもそれなりに動くようにはできますが、実行順をちゃんと制御できるなら完全なランタイム時にIDを生成しても動くものが作れるのでメモとして書いてみました。

ランタイムでAvatarを生成してアニメーションに利用する

今日の記事は、UnityAdvent Calendarの9日目の記事です。

qiita.com

概要

VR開発をしていると、キャタクター(アバター)を表現する方法として頭と手だけの簡易的アバターか、通常のキャラクターモデルを用いたアバターの2種類から選ぶことになります。
そして後者、キャラクターモデルを利用したアバターを制作する際に利用できそうな方法を見つけたので、それのメモです。

具体的には、Avatarインスタンスをランタイム時に生成し、それをMecanimとしてキャラクターにアサインする方法となります。

ちなみに、今回の実装のヒントは、OptiTrackというモーションキャプチャーのシステム用に提供されているプラグインの中のソースコードを参考にしました。

プラグインはフリーで、以下からダウンロードすることができます。

optitrack.com

実際に使ってみたのがこちら。Avatarなのでアニメーションを簡単にコピー、複製することができます。

この記事のサンプルはGitHubに上がっています。(ただ、元のサンプルはVRIKを使っているため、その部分はコメントアウトしてあります。もし実際に動くものを見たい場合はご自身でVRIKを導入してご確認ください)

github.com

必要クラス

今回利用するクラスは以下です。
それぞれが、アバターを構成するための情報を保持、伝達するためのものです。

  • Avatar
  • AvatarBuilder
  • HumanBone
  • SkeltonBone
  • HumanDescription
  • HumanPose
  • HumanPoseHandler
  • HumanTrait

大まかな手順

大まかな手順は、(Avatarの設定を行ったことがある方であればイメージしやすいと思いますが)Configure Avatarボタンを押下して編集モードに入ったときにできることをプログラムから行う、というイメージです。

具体的には、HumanBoneSkeletonBoneを用いてスケルトンとモデルの構造を定義し、それらを関連付け、最後に関節の曲がり具合などを設定した「設定オブジェクト(HumanDescription)」とともに、Avatarを生成する、という形です。

この設定を行う際、適切にセットアップが終わっていないと最後のAvatarBuilderでビルドする段階でエラーが出てしまうので、セットアップは気をつける必要があります。

百聞は一見にしかず、ということで、まずは実際にコードを見てもらったほうがいいでしょう。

Avatarのセットアップコード

各種ボーンの設定と、アバターのビルドを行うメソッドの抜粋です。

/// <summary>
/// アバターのセットアップ
/// </summary>
private void Setup()
{
    // HumanBoneのためのリストを取得する
    string[] humanTraitBoneNames = HumanTrait.BoneName;

    List<HumanBone> humanBones = new List<HumanBone>(humanTraitBoneNames.Length);
    for (int i = 0; i < humanTraitBoneNames.Length; i++)
    {
        string humanBoneName = humanTraitBoneNames[i];
        Transform bone;
        if (_transformDefinision.TryGetValue(humanBoneName, out bone))
        {
            HumanBone humanBone = new HumanBone();
            humanBone.humanName = humanBoneName;
            humanBone.boneName = bone.name;
            humanBone.limit.useDefaultValues = true;

            humanBones.Add(humanBone);
        }
    }

    List<SkeletonBone> skeletonBones = new List<SkeletonBone>(_skeletonBones.Count);
    for (int i = 0; i < _skeletonBones.Count; i++)
    {
        Transform bone = _skeletonBones[i];

        SkeletonBone skelBone = new SkeletonBone();
        skelBone.name = bone.name;
        skelBone.position = bone.localPosition;
        skelBone.rotation = bone.localRotation;
        skelBone.scale = Vector3.one;

        skeletonBones.Add(skelBone);
    }

    // HumanDescription(関節の曲がり方などを定義した構造体)
    HumanDescription humanDesc = new HumanDescription();
    humanDesc.human = humanBones.ToArray();
    humanDesc.skeleton = skeletonBones.ToArray();

    humanDesc.upperArmTwist = 0.5f;
    humanDesc.lowerArmTwist = 0.5f;
    humanDesc.upperLegTwist = 0.5f;
    humanDesc.lowerLegTwist = 0.5f;
    humanDesc.armStretch = 0.05f;
    humanDesc.legStretch = 0.05f;
    humanDesc.feetSpacing = 0.0f;
    humanDesc.hasTranslationDoF = false;

    // アバターオブジェクトをビルド
    _srcAvatar = AvatarBuilder.BuildHumanAvatar(gameObject, humanDesc);

    if (!_srcAvatar.isValid || !_srcAvatar.isHuman)
    {
        Debug.LogError("setup error");
        return;
    }

    _srchandler = new HumanPoseHandler(_srcAvatar, transform);
    _destHandler = new HumanPoseHandler(_destAvatar, _targetAnimator.transform);

    _initialized = true;
}

ボーンのセットアップ

まず冒頭で行っているのが、ボーンのセットアップです。
ボーンには2種類あり、HumanBoneSkeletonBoneの2種類です。

「人間の構造」を定義する「HumanBone」と実際の「SkeletonBone」

あくまで自分の理解で、という前置きが入りますが、HumanBone人間の構造を定義するためのボーンです。
そして実際のモデル(アバターに適用するオブジェクト)のボーン構造を示すのがSkeletonBoneです。

なぜこのふたつのボーン情報が必要なのかというと、モデルの中身を見たことがある人であればすぐにピンと来ると思いますが、モデルデータには人間にはないボーンが仕込まれている場合があります。
MMDなどは特にそれが顕著で、「よりよく見せるため」のボーンが仕込まれていたりします。
(例えばスカート用のボーンだったり、髪の毛用のボーンだったり)

そのため、人間の構造と同じ構造でボーンを定義することはほとんどなく、いくらかのボーンが人間の構造とは違った形になっているため、「実際のボーン構造のうち、どれが人間の構造としてのボーンか」を定義する必要がある、というわけです。(と理解しています)

そして、その関連付けを行っているのが、それぞれnameプロパティで指定される名称です。
どうやらUnity内では名称でそのマッチングを行っているようです。

なので、「人間としてのこのボーンは、対象モデルではこういう名称ですよ」という関連付けが必要、というわけですね。

コードとしては以下の部分ですね。

humanBone.boneName = bone.name;

// ... 中略 ...

skelBone.name = bone.name;

そして、「人間としてのどこのボーンか」という情報はhumanBone.humanName = humanBoneName;で指定しています。

こうして、人間としてのボーンがどれか、というマッチングを行うことで、Mecanimではその情報を元にアニメーションしている、というわけのようです。

最初、SkeletonBoneが人間のボーン構造を示すものだと思って、それだけのTransformを指定して配列を生成していたんですが、「hoge Transformは fuga Transformの親じゃないとダメだよ」みたいなエラーが出て、少しハマりました。
今回の実装ではSkeletonBoneは以下のように、ルートから再帰的にTransform情報を拾って配列化して設定しています。

/// <summary>
/// 再帰的にTransformを走査して、ボーン構造を生成する
/// </summary>
/// <param name="current">現在のTransform</param>
private void RecursiveSkeleton(Transform current, ref List<Transform> skeletons)
{
    skeletons.Add(current);

    for (int i = 0; i < current.childCount; i++)
    {
        Transform child = current.GetChild(i);
        RecursiveSkeleton(child, ref skeletons);
    }
}

Unityが規定した名称を基に各ボーンの関連付けを行う

さて、もうひとつ重要なのがこの「Unityが規定した名称を基に関連付けを行う」という点です。
どういうことかというと、まずは以下のコードを見てください。

string[] humanTraitBoneNames = HumanTrait.BoneName;

List<HumanBone> humanBones = new List<HumanBone>(humanTraitBoneNames.Length);
for (int i = 0; i < humanTraitBoneNames.Length; i++)
{
    string humanBoneName = humanTraitBoneNames[i];
    Transform bone;
    if (_transformDefinision.TryGetValue(humanBoneName, out bone))
    {
        HumanBone humanBone = new HumanBone();
        humanBone.humanName = humanBoneName;
        humanBone.boneName = bone.name;
        humanBone.limit.useDefaultValues = true;

        humanBones.Add(humanBone);
    }
}

HumanTrait.BoneNameというstring型の配列から値を取り出し、それと、自分が定義した_transformDefinisionの中に値が含まれているかのチェックをしています。

このHumanTrait.BoneNameが、Unityが規定しているボーンの名称で、具体的にはNeckなどの人体の部位の名称が設定されています。

そしてここで定義されている名称とのマッピングを行っているのが_transformDefinisionなのです。

これ自体はシンプルに、インスペクタから手で設定してもらったTransformを設定しているだけです。
生成部分は以下のようになります。

/// <summary>
/// アサインされたTransformからボーンのリストをセットアップする
/// </summary>
private void SetupBones()
{
    _transformDefinision.Clear();

    _transformDefinision.Add("Hips", _hips);
    _transformDefinision.Add("Spine", _spine);
    _transformDefinision.Add("Chest", _chest);
    _transformDefinision.Add("Neck", _neck);
    _transformDefinision.Add("Head", _head);
    _transformDefinision.Add("LeftShoulder", _leftShoulder);
    _transformDefinision.Add("LeftUpperArm", _leftUpperArm);
    _transformDefinision.Add("LeftLowerArm", _leftLowerArm);
    _transformDefinision.Add("LeftHand", _leftHand);
    _transformDefinision.Add("RightShoulder", _rightShoulder);
    _transformDefinision.Add("RightUpperArm", _rightUpperArm);
    _transformDefinision.Add("RightLowerArm", _rightLowerArm);
    _transformDefinision.Add("RightHand", _rightHand);
    _transformDefinision.Add("LeftUpperLeg", _leftUpperLeg);
    _transformDefinision.Add("LeftLowerLeg", _leftLowerLeg);
    _transformDefinision.Add("LeftFoot", _leftFoot);
    _transformDefinision.Add("RightUpperLeg", _rightUpperLeg);
    _transformDefinision.Add("RightLowerLeg", _rightLowerLeg);
    _transformDefinision.Add("RightFoot", _rightFoot);
    _transformDefinision.Add("LeftToes", _leftToes);
    _transformDefinision.Add("RightToes", _rightToes);
}

あとは、この設定されたリストとマッチングして、該当のボーンの名称を、前述のように設定していく、という感じになります。
再掲すると以下の部分です。

humanBone.boneName = bone.name;

// ... 中略 ...

skelBone.name = bone.name;

人間の特性を定義する「HumanDescription」

ボーンのセットアップが終わったら、そのボーン構造を持つ人の特性がどんなものか、を定義する「HumanDescription」構造体を利用して、手の関節の回転などの状態を定義します。

HumanDescription humanDesc = new HumanDescription();
humanDesc.human = humanBones.ToArray();
humanDesc.skeleton = skeletonBones.ToArray();

humanDesc.upperArmTwist = 0.5f;
humanDesc.lowerArmTwist = 0.5f;
humanDesc.upperLegTwist = 0.5f;
humanDesc.lowerLegTwist = 0.5f;
humanDesc.armStretch = 0.05f;
humanDesc.legStretch = 0.05f;
humanDesc.feetSpacing = 0.0f;
humanDesc.hasTranslationDoF = false;

アバターをビルド

以上で必要なデータが揃いました。
あとは、そのデータを利用して、アバターオブジェクトをビルドしてやれば完了です。

_srcAvatar = AvatarBuilder.BuildHumanAvatar(gameObject, humanDesc);

if (!_srcAvatar.isValid || !_srcAvatar.isHuman)
{
    Debug.LogError("setup error");
    return;
}

注意点として、ビルド後にエラーがないかのチェックが必要です。

上でも書きましたが、ボーンの構造などの状態がおかしいと、この時点でエラーが表示されます。
構造として適切でない場合はビルド時にエラーが出るのと同時に、isValidisHumanのフラグがfalseになるので、それをチェックして、失敗していた場合はやり直すなどの処置が必要になります。
(最初はここでエラーが出て、若干ハマった)

ただ、エラーを見てみるとしっかりと理由が書かれているので、それを元に修正していけば問題は解決できると思います。

無事ビルドが終わったら、HumanPoseHandlerを設定し、以後はUpdateメソッド内でアバターの状態をコピーしてやれば完成です。

HumanPoseHandlerは、現在の状態を取得するためのハンドラ。

_srchandler = new HumanPoseHandler(_srcAvatar, transform);
_destHandler = new HumanPoseHandler(_destAvatar, _targetAnimator.transform);

上記で取得したハンドラを用いてGet/Setメソッドを用いて、Getしたポーズを、以下のようにして対象のアバターにコピーします。

private void Update()
{
    if (!_initialized)
    {
        return;
    }

    if (_srchandler != null && _destHandler != null)
    {
        _srchandler.GetHumanPose(ref _humanPose);
        _destHandler.SetHumanPose(ref _humanPose);
    }
}

GetHumanPoseメソッドでポーズ情報を取得し、SetHumanPoseでコピー先のHumanPoseHandlerにセットしてやれば動きが同期されるようになります。

ちなみに、感のいい方であればピンと来ているかもしれませんが、このコピー先を複数用意してやれば、いくらでも同じ動きをするモデルを用意することができます。
Mecanimによるアバター制御の恩恵が受けられる、というわけですね。

f:id:edo_m18:20170923153401p:plain

最後に

冒頭で載せた動画は、このアバターの仕組みを使って「アバターの現在のアニメーション状態をコピー」することで実現しています。

Avatarをランタイムで生成できることで、色々な値を調整することが可能になるのでVRには非常に適したものかなと思っています。

ARKitで撮影した映像を(疑似)IBLとして利用する

今日の記事は、ARKitAdvent Calendarの2日目の記事です。

qiita.com

概要

今回は、ARKitで平面検出を行っている映像データを使って、(疑似)IBLをしてみたいと思います。

ちなみに、ARKitをUnityで使う際の実装については前の記事で少し書いたので、ARKit?って人はそっちも読んでみてください。

edom18.hateblo.jp

ARで3Dモデルを表示するととても面白いしテンション上がりますが、やはりどこか浮いて見えてしまいます。
というのも、人間は立体感を「影・陰」から判断しているため、光の当たり方が少し違うだけで違和感が出てしまうのです。

そして当然ですが、なにもしなければ3Dモデルを照らすライトはビルドしたときに用意したライトのみになります。

しかしARに関わらず、Skyboxのテクセルを光源とみなす、いわゆる「グローバルイルミネーション」の機能を使えば、映像からライティングが可能となります。

今回の趣旨は、ARKitが利用している映像を利用して、疑似IBLを実現してみよう、という内容になります。
なので厳密には、IBL自体を自前で実装したわけではなく、あくまで疑似IBLです。

実際に適用した図が以下になります↓

考え方

考え方はシンプルです。

  • ARKitで利用される環境のテクスチャを取得する
  • 取得したテクスチャを適度にぼかす(*1)
  • ぼかしたテクスチャを、全天球に貼り付ける
  • 全天球の中心に置いた専用カメラでCubeMapにレンダリングする
  • 生成したCubeMapから色をフェッチしてブレンド

という手順です。

*1 ぼかす理由は、IBL自体がそもそも、描画する点から複数方向に向かってサンプルRayを飛ばし、その色を合成することで得られます。
それは、環境光が全方面(点を中心とした半球状の方向)から到達するためであり、それをシミュレーションするために様々な方向の光をサンプリングするわけです。
そしてそれを擬似的に、かつ簡易的に実現する方法として「ぼかし」を利用しているわけです。

以前書いたPBRについての記事も参考になると思います。(光のサンプリングという点で)

qiita.com

ARKitのテクスチャからぼかしテクスチャを得る

今回の目的達成のために、若干、ARKitのプラグインUnityARVideoクラスのコードを編集しました。

_unityARVideo.VideoTextureY;
_unityARVideo.VideoTextureCbCr;

本来はVideTextureがprivateなフィールドのためアクセスできませんが、IBL用にpublicにして取得できるようにしてあります。

実際にブラーを施している箇所は以下のようになります。

for (int i = 0; i < _renerList.Count; i++)
{
    _renerList[i].material.SetFloat(“_IBLIntencity”, _IBLIntencity);
}

Texture2D textureY = _unityARVideo.VideoTextureY;
Texture2D textureCbCr = _unityARVideo.VideoTextureCbCr;

_yuvMat.SetTexture(“_textureY”, textureY);
_yuvMat.SetTexture(“_textureCbCr”, textureCbCr);

Graphics.Blit(null, _ARTexture, _yuvMat);
_blur.ExecuteBlur(_ARTexture, _bluredTexture);

_camera.RenderToCubemap(_cubeMap);

UnityARVideoクラスからテクスチャを取り出して、それをひとまず専用のRenderTextureにレンダリングします。
そしてレンダリングされた結果を、ブラー用のマテリアルでレンダリングしたものを全天球のテクスチャにします。
(上のコードにはありませんが、セットアップの時点で_bluredTextureが適切に割り当てられています)

そして最後に、_camera.RenderToCubemap(_cubeMap);を実行して、ぼかしたテクスチャをまとった全天球をCubemapに書き出している、というわけです。

キャラのシェーダ

キャラのシェーダのコード断片を載せておきます。

v2f vert(appdata i)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(i.vertex);
    o.normal = UnityObjectToWorldNormal(i.normal);
    o.uv = i.texcoord;
    return o;
}

float4 frag(v2f i) : SV_Target
{
    float4 tex = tex2D(_MainTex, i.uv);
    float4 cube = texCUBE(_Cube, i.normal) * _IBLIntencity;
    return tex * cube;
}

やっていることはシンプルに、テクスチャの色と、生成したCubeMapからの色を合成しているだけですね。
いちおう、あとから調整できるようにIntencityも用意してあります。

ただあくまで今回のやつは疑似的なものです。
そもそも、カメラで撮影している前面の映像しかないですし、本来適用されたい色とはずれているため、色味を調整する、くらいの感じで利用するのがいいかなと思います。
とはいえ、環境光にまったく影響を受けないのはそれはそれで違和感があるので、少しでも変化があるとより自然になるのではないでしょうか。

その他メモ

さて、今回は以上なんですが、サンプルの実装をするにあたって、いくつか別のアプローチも試していたので、せっかくなのでメモとして残しておこうと思います。

Skyboxのマテリアルで直接レンダリング

前述したものは、オブジェクトとして全天球を用意してそれをカメラでCubemapに変換する方式でした。
こちらは、Skyboxのマテリアルとしてのシェーダを書いて実現しようとしたものです。

Unityでは、環境マップ用にSkyboxのマテリアルが設定できるようになっています。
そのマテリアルには、他のマテリアルとは若干異なる値が渡されます。

これを用いて、Skyboxのレンダリング結果自体を操作することで実現しようとしたものです。

具体的には、前述の例と同じくUnityARVideoからYCbCrの2種類のテクスチャを取得するところまでは同様です。
それを直接、Skyboxのマテリアルにセットし、シェーダ内ではUV座標を極座標に変換してダイレクトに、フェッチする位置を計算する、というものです。

極座標への変換については以前記事に書いたので参考にしてみてください。

qiita.com

まずはそのシェーダを下に書きます。

Skyboxシェーダ

Shader "Skybox/ARSkybox"
{
    Properties
    {
        _TextureY("TextureY", 2D) = "white" {}
        _TextureCbCr("TextureCbCr", 2D) = "black" {}
    }

   CGINCLUDE

   #include "UnityCG.cginc"

   #define PI 3.141592653589793

   struct appdata
    {
        float4 position : POSITION;
        float3 texcoord : TEXCOORD0;
    };
    
   struct v2f
    {
        float4 position : SV_POSITION;
        float3 texcoord : TEXCOORD0;
    };

    float4x4 _DisplayTransform;
    
   sampler2D _MainTex;
    sampler2D _TextureY;
    sampler2D _TextureCbCr;
    
   v2f vert (appdata v)
    {
        v2f o;
        o.position = UnityObjectToClipPos (v.position);
        o.texcoord = v.texcoord;
        return o;
    }
    
   half4 frag (v2f i) : COLOR
    {
        float u = atan2(i.texcoord.z, i.texcoord.x) / PI;
        float v = acos(i.texcoord.y) / PI;
        float2 uv = float2(v, u);

        //
        // 式を調べたら以下のものだったが、ARKitで使ってるのは少し違う?
        //
        // Y  =  0.299R + 0.587G + 0.114B
        // Cr =  0.500R - 0.419G - 0.081B
        // Cb = -0.169R - 0.332G + 0.500B
        //
        // | Y  | = |  0.299,  0.587,  0.114 |   | R |
        // | Cr | = |  0.500, -0.419, -0.081 | x | G |
        // | Cb | = | -0.169, -0.332,  0.500 |   | B |
        //
        // 逆行列をかけて求める。
        //
        // R = Y + 1.402Cr
        // G = Y - 0.714Cr - 0.344Cb
        // B = Y + 1.772Cb

        //
        // 計算結果が異なったが、いちおう残しておく
        //
        // float y = tex2D(_TextureY, uv).r;
        // float2 cbcr = tex2D(_TextureCbCr, uv).rg;
        // float r = y + 1.402 * cbcr.g;
        // float g = y - 0.714 * cbcr.g - 0.344 * cbcr.r;
        // float b = y + 1.772 * cbcr.r;
        // 
        // return float4(r, g, b, 1.0);

        float y = tex2D(_TextureY, uv).r;
        float4 ycbcr = float4(y, tex2D(_TextureCbCr, uv).rg, 1.0);

        const float4x4 ycbcrToRGBTransform = float4x4(
            float4(1.0, +0.0000, +1.4020, -0.7010),
            float4(1.0, -0.3441, -0.7141, +0.5291),
            float4(1.0, +1.7720, +0.0000, -0.8860),
            float4(0.0, +0.0000, +0.0000, +1.0000)
        );

        return mul(ycbcrToRGBTransform, ycbcr);
    }

   ENDCG

   SubShader
    {
        Tags { "RenderType"="Background" "Queue"="Background" }
        Pass
        {
            ZWrite Off
            Cull Off
            Fog { Mode Off }

           CGPROGRAM
            #pragma fragmentoption ARB_precision_hint_fastest
            #pragma vertex vert
            #pragma fragment frag
            ENDCG
        }
    } 
}

やっていることは、ARCameraから得たふたつのテクスチャ(※)を合成して、さらに全天球の位置を想定して、フェッチするUV座標を計算しています。
※ YCbCrフォーマットなので、YテクスチャとCbCrテクスチャの2枚を合成する必要があります。

Skybox用シェーダに渡されるtexcoord

通常、シェーダに渡されるtexcoordは対象のモデルのUVの値が使われます。
しかしSkyboxの場合はそもそもモデルデータではなく仮想のもののため、通常のtexcoordの値とは異なった値が渡ってきます。
(ちなみにSkyboxのtexcoordfloat3型)

ではどんな値が渡ってくるのかというと、float3型の値で、ワールド空間でのXYZ方向が渡ってきます。
例えばZ軸方向に向いているベクトルは(0, 0, 1)、真上方向は(0, 1, 0)、といった具合に、ワールド空間での、原点からの方向ベクトルがそのまま渡ってきます。

なのでそれを想定して、以下のよう、極座標に位置するテクセルをフェッチするようなイメージでフェッチする位置を変換します。
※ 以下のコードは実際に使っているものではなく、通常のテクスチャからフェッチする場合の計算です。

float u = 1 - atan2(i.texcoord.z, i.texcoord.x) / PI;
float v = 1 - acos(i.texcoord.y) / PI;
float2 uv = float2(u, v);

やっていることはまず、V座標についてはY軸方向をフェッチするため、単純にYの値からアークコサインで角度を求め、それを$\pi$、つまり180°で割ることで正規化しています。
さらに上下を逆転させるため、その値を1から引き、最終的な位置を決定しています。

続いてU座標については、XZ平面でのベクトルの角度を求め、それを$\pi$で正規化することで得ています。
※ ちなみに、U座標については本来は360°の角度がありますが、「見ている方向」に限定すると180°がちょうどいいので、あえて180°で正規化し、正面と背面で同じテクスチャを利用するようにしています。

実際には、ARCameraから得られる結果が若干回転した画像になっていたため、以下のように調整しました。

float u = acos(i.texcoord.y) / PI;
float v = atan2(i.texcoord.z, i.texcoord.x) / PI;

※ uとvの計算が逆になっていることに注意。

以上のように設定することで、下の図のように映像が全天球状態で表示されるようになります。

f:id:edo_m18:20171202215058p:plain

ハマったメモ

今回の例では(最終的には)問題なくなったんですが、ちょっとハマったのと知っておくといいかなと思った点をメモとして残しておきます。

ずばり、Cubemapを動的に反映させる方法、です。
最初、普通にRenderToCubemapを使ってCubemapにレンダリングしていたんですが、どうも最初の一回しか更新してくれない。(毎フレーム更新処理しているのに)

なんでかなーと色々調べていたところ、いくつかのパラメータ設定と更新の通知処理をしないとならないようでした。
そのときに実際に書いたコードを載せておきます。

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

public class CubeMapGenerator : MonoBehaviour
{
    [SerializeField]
    private Camera _otherCamera;

    [SerializeField]
    private Cubemap _cubemap;

    [SerializeField]
    private Material _material;

    private void Start()
    {
        Debug.Log(RenderSettings.defaultReflectionMode);
        RenderSettings.defaultReflectionMode = UnityEngine.Rendering.DefaultReflectionMode.Custom;
    }

    private void LateUpdate()
    {
        if (Input.GetKeyDown(KeyCode.A))
        {
            _otherCamera.RenderToCubemap(_cubemap);
            _cubemap.Apply();
            DynamicGI.UpdateEnvironment();
            RenderSettings.customReflection = _cubemap;
        }

        _otherCamera.transform.Rotate(Vector3.up, 1f);
    }
}

こんな感じで、Cubemapを更新してやらないと2回目以降のものが反映されませんでした。

UnityでThreadを使って処理を分割する

概要

Unityではスレッドを使うことが想定されていません。
というのも、いわゆる「Unity API」と呼ばれる様々なUnityの機能が、メインスレッド以外からは呼び出せない仕様となっているからです。
UIはメインスレッドからのみ操作できるというのと似ていますね。

とはいえ、昨今のゲームでは負荷の高い処理を行う必要があることも少なくありません。
そこで、Unity上でもスレッドを扱う必要が出てきます。

ということで、今回はUnityでスレッドを使う上での注意や実際に使う場合の処理などを書きたいと思います。

今回の記事を書くにあたって、処理負荷軽減の恩恵を感じられるように、Flocking、いわゆる群衆シミュレーションに似た処理をスレッドによって軽減するようにしてみました。
(ただ正直、スレッドの扱いにはそこまで慣れてないのでなんか変なところあったらツッコミ入れてください;)

なお、今回のデモはGithubにアップしてあります。

Flocking

フロッキングとは、いわゆる群衆シミュレーションと呼ばれる、生物が集団で移動する際の状況を「それっぽく」見せるためのアルゴリズムです。
実装自体はとてもシンプルで、いくつかのシンプルな実装を組み合わせるだけで、まるで鳥が集団で飛んでいるかのような状況を作り出すことができます。(Birdroidを短縮してBoid、と呼ばれるのも同じものです)

今回はこのアルゴリズムのうち、いくつかを組み合わせて、リーダー機に従い、それぞれの僚機が一定距離を保って飛行する、という感じのものを作ってみました。

↓こんな感じ。機体の追加と、ターゲットにまとわりつく、みたいな処理のつもりw

こちらの記事(【ゲームAI】フロッキングアルゴリズム)がAIとしてのフロッキングについて解説しているので興味がある人は読んでみてください。

スレッドを使う

今回のサンプルを実装する上で使用したスレッド関連のクラスは以下です。

  • ManualResetEvent
  • Thread

今回の例はシンプルなもののため、スレッドプールなどは使っていません。
また、Unity2017からはC#5.0以降で使えるawaitasyncが使えるようになります。
そのため、Taskなども使えるようになりますが、今回はスレッド自体の説明のためそれらは使用していません。

www.buildinsider.net

qiita.com

(今回のサンプルでは)ManualResetEventクラスを用いて、シグナルを切り替えながら同期処理を行います。
イメージは「信号機」です。セマフォも似た仕組みですね。

ManualResetEventを使い、Resetメソッドで「非シグナル状態」にします。
そしてその後、WaitOneメソッドを実行すると、スレッドはそこで待機状態となり、次にシグナルがオンになるまで停止されます。
シグナルがオンになったら(つまり信号が青になったら)スレッドが再開され、停止していた位置から処理を再開します。

各寮機の位置を更新するクラス

public class UnitWorker
{
    // 非シグナル状態で初期化
    private readonly ManualResetEvent _mre = new ManualResetEvent(false);

    private Thread _thread;
    private bool _isRunning = false;

    private float _timeStep = 0;

    public List<UnitBase> Units { get; set; }

    // コンストラクタ
    public UnitWorker()
    {
        Initialize();
    }

    // 初期化処理
    // スレッドを生成し、スタートさせておく
    private void Initialize()
    {
        _thread = new Thread(ThreadRun);
        _thread.IsBackground = true;
        _thread.Start();
    }

    // スレッドの再開を外部から伝える
    public void Run()
    {
        _timeStep = Time.deltaTime;
        _isRunning = true;
        _mre.Set();
    }

    // 実際の位置計算処理を実行
    private void Calculate()
    {
        UnitBase unit;
        for (int i = 0; i < Units.Count; i++)
        {
            unit = Units[i];
            unit.UpdatePosition(_timeStep);
        }
    }

    // サブスレッドで実行される処理
    private void ThreadRun()
    {
        // シグナル状態になるのを待機する
        _mre.WaitOne();

        try
        {
            // 位置計算のアップデート
            Calculate();
        }
        finally
        {
            // 最後に、非シグナル状態に戻して次回の実行が待機されるようにする
            _isRunning = false;

            _mre.Reset();

            // 新しいスレッドを作ってスタートさせておく(初期化と同じ)
            _thread = new Thread(ThreadRun);
            _thread.IsBackground = true;
            _thread.Start();
        }
    }
}

ユニットを生成・管理するクラス

using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using UnityEngine;

public class DroneFactory : MonoBehaviour
{
    #region ### Variables ###
    [SerializeField]
    private Transform _leader;

    [SerializeField]
    private Transform _target;

    [SerializeField]
    private GameObject _unitPrefab;

    [SerializeField]
    private SteamVR_TrackedController _controller;

    private List<UnitBase> _units = new List<UnitBase>();
    public List<UnitBase> Units
    {
        get { return _units; }
    }

    private UnitWorker[] _unitWorkers = new UnitWorker[4];

    private bool _needsStopThread = false;
    #endregion ### Variables ###

    #region ### MonoBehaviour ###
    private void Start()
    {
        _units = new List<UnitBase>(GetComponentsInChildren<UnitBase>());

        for (int i = 0; i < _unitWorkers.Length; i++)
        {
            _unitWorkers[i] = new UnitWorker();
        }

        GiveUnits();
    }

    private void Update()
    {
        if (Time.frameCount % 5 == 0)
        {
            if (_controller.triggerPressed)
            {
                Injetion();
            }

            if (_controller.menuPressed)
            {
                GenerateUnit();
            }
        }

        for (int i = 0; i < _unitWorkers.Length; i++)
        {
            _unitWorkers[i].Run();
        }
    }
    #endregion ### MonoBehaviour ###

    /// <summary>
    /// 生成した4スレッド分に、計算するユニットを分配する
    /// </summary>
    private void GiveUnits()
    {
        int len = _unitWorkers.Length;
        int range = _units.Count / len;
        for (int i = 0; i < _unitWorkers.Length; i++)
        {
            List<UnitBase> units = _units.GetRange(range * i, range);
            _unitWorkers[i].Units = units;
        }
    }

    /// <summary>
    /// ユニットをターゲットに向けて射出する
    /// </summary>
    private void Injetion()
    {
        UnitBase unit = Units.FirstOrDefault(u => u.Target != _target);
        if (unit != null)
        {
            unit.Target = _target;
        }
    }

    /// <summary>
    /// ユニットを生成してリストに追加する
    /// </summary>
    private void GenerateUnit()
    {
        GameObject unitObj = Instantiate(_unitPrefab, _controller.transform.position, Quaternion.identity);
        UnitBase unit = unitObj.GetComponent<UnitBase>();
        unit.Leader = _leader;
        unit.Speed = Random.Range(0.2f, 0.5f);
        _units.Add(unit);

        GiveUnits();
    }
}

以上が、今回のサンプルの肝部分です。

解説

今回のサンプルは、ManualResetEventを使ってシグナル状態を管理、適切なタイミングでスレッドを起動し、位置を計算、計算後にそれを適用する、という流れになっています。
ポイントはスレッドの生成部分です。

実際はスレッドプールなどを生成して再利用しないと、毎フレームごとにスレッドを生成しているのでコストが高いですが、今回は分かりやすさ重視ということでこういう実装をしています。
スレッドを理解するには、スレッドは、OSからスケジューリングされて、決められた時間だけCPUを使い、計算を行う、という点です。

そのため、今回の_mre.WaitOne()のように、スレッド自体を停止させると、シグナル状態になるまでその処理が停止します。
メインスレッドで常に実行されるStartUpdateは、こうした「停止」処理自体が行なえません。

※ 厳密には、メインスレッドを停止してしまうと画面が固まって見えてしまうので、原則としてメインスレッドを待機状態にすることはまずないでしょう。
結局のところ、メインスレッドも「スレッドのひとつ」であることに変わりはないので、スレッドに対して行える処理は同様に行うことができます。

ざっくりと、理解の助けとなる手順を書くと以下のようになります。

  1. メソッド(ThreadRun)を、生成したスレッドに割り当ててそれを実行状態にする(Thread.Start
  2. ThreadRunメソッドは実行されてすぐに、_mre.WaitOne()によってシグナルを待つ状態に移行する
  3. Runメソッドが実行されると_mre.Set()が呼ばれ、シグナル状態となり、停止していたスレッドが動き出す
  4. スレッド(ThreadRun)の実行は、位置計算の更新処理後、最後のタイミングで再び非シグナル状態に戻し、さらに新しくスレッドを生成して終了する
  5. そして再び_mre.WaitOne()によってスレッドが停止され、以後それを繰り返す

という流れになります。

今回のサンプルではこの、「シグナル状態」が分かれば特にむずかしいことはないと思います。

参考記事

気になるとワスレルナ スレッドプログラミング AutoResetEvent

smdn.jp

スレッドプールの仕組みを作る

さて最後に、少しだけThreadPoolの仕組みを簡単に自作したものを載せておきます。
(ただ前述した通り、スレッドの扱いがまだ慣れてないので、あくまで自分の理解のために書いた感じなので注意してください)

参考: C#非同期処理関連のMSDNの資料読んでみた(2)

使うクラス

  • Thread
  • AutoResetEvent
  • WaitCallback

AutoResetEvent

前述のサンプルでも登場したManualResetEventですが、AutoResetEventというものもあります。 違いは以下です。

イベント待機ハンドル(WaitHandle)により、スレッドは相互に通知を行い、相手の通知を待機して動作を同期することができます。
イベント待機ハンドルは通知されたときに、自動的にリセットされるイベントと手動でリセットするイベントと2種類に分けられます。

ManualとAutoの違いはまさにこの「自動リセット」か「手動リセット」かの違いとなります。

AutoResetEventは待機中のスレッドがなくなると自動的に非シグナル状態へと遷移します。
一方、ManualResetEventは、Reset()を呼び出し、手動で非シグナル状態に戻す必要があります。

以下の記事が、ManualとAutoの違いの比較コードを載せてくれているので、興味がある人は読んでみてください。
参考: https://codezine.jp/article/detail/139#waithandle

※ それぞれのクラスはWaitHandleクラスを継承した派生クラスとなっています。

WaitHandle

Win32同期ハンドルをカプセル化し、複数の待機操作を実行するための抽象クラス。
派生クラスには上記以外に、Mutex, EventWaitHandle, Semaphoreなどがあります。

「待機ハンドル」と呼ばれるWaitHandleオブジェクトは、スレッドの同期に使われます。
待機ハンドルの状態には「シグナル状態」と「非シグナル状態」の2つがあり、待機ハンドルをどのスレッドも所有していなければ「シグナル状態」、所有していれば「非シグナル状態」となります。
WaitHandle.WaitOneメソッドなどを使うことにより、待機ハンドルがシグナル状態になるまでスレッドをブロックすることができます。
イメージ的には、「シグナル状態」は「青信号」で「非シグナル状態」は「赤信号」です。
つまり、非シグナル状態=赤信号の場合は、シグナル状態=青信号になるまで待機する、というわけですね。

WaitCallback

Define:
[ComVisibleAttribute(true)]
public delegate void WaitCallback(object state);

state ... コールバックメソッドが使用する情報を格納したオブジェクト。void*型と思えばよさげ。

コードサンプル

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

public class SimpleThreadPool : MonoBehaviour
{
    /// <summary>
    /// サブスレッドタスク
    /// </summary>
    class Task
    {
        public WaitCallback Callback;
        public object Args;
    }

    private Queue<Task> _taskQueue = new Queue<Task>();
    private Thread _thread;
    private AutoResetEvent _are = new AutoResetEvent(false);
    private bool _isRunning = false;

    private int _id = 0;

    private void Start()
    {
        _isRunning = true;
        _thread = new Thread(new ThreadStart(ThreadProc));
        _thread.Start();
    }

    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.T))
        {
            AddTask();
        }

        if (Input.GetKeyDown(KeyCode.A))
        {
            for (int i = 0; i < 30; i++)
            {
                AddTask();
            }
        }
    }

    private void AddTask()
    {
        Task task = new Task
        {
            Callback = new WaitCallback(TaskProc),
            Args = (object)_id++,
        };

        _taskQueue.Enqueue(task);

        Debug.Log("Added task. Task count is " + _taskQueue.Count);

        if (_taskQueue.Count == 1)
        {
            _are.Set();
        }
    }

    private void TaskProc(object args)
    {
        Debug.Log("Task Proc.");

        Thread.Sleep(500);

        int id = (int)args;
        Debug.LogFormat("Task {0} is finished.", id);

        _are.Set();
    }

    private void ThreadProc()
    {
        while (_isRunning)
        {
            _are.WaitOne();

            if (_taskQueue.Count > 0)
            {
                Task task = _taskQueue.Dequeue();
                task.Callback(task.Args);
            }
        }
    }
}

こちらのサンプルでは、常にタスクを監視して実行するThreadProcをサブスレッドで実行し、タスクキューにタスクを追加することでスレッド処理を行っているサンプルです。
タスクが追加されるまではスレッドは停止状態になりますが、タスクが追加されるとスレッドが起動されて、キューからタスクを取り出し実行します。

今回はサンプルのため、タスク処理の中でシグナル状態を制御していますが、汎用的にタスクを追加することを考えるとここは内部で適切に管理する必要があるでしょう。

WaitCallbackでタスクを登録する

タスクの処理はTaskProcで行っていますが、タスク自体はWaitCallbackクラスに、処理してもらいたいメソッドを登録して生成しています。
定義はdelegateになっていて、object型の引数をひとつ受け取るデリゲートです。

なので、void*型のように使用して、内部で適切にキャストしてあげる必要があります。

このように、スレッドを必要数起動させておいて、タスクをあとからキューに追加する形で実行するので、スレッドの新規生成を挟まず、生成コストを削減することができるようになります。

その他

以前、C言語のスレッドについて、書籍からのメモを書いた記事もあるので、そちらも合わせて読んでみてください。

qiita.com

iOSのARKitを使ってVRのポジトラをやってみた

概要

ずっと気になっていたARKit。やっと触ることができたので、ひとまず、空間認識して色々触ったあと、VRのポジトラに流用するのをやってみたのでまとめておきます。
AR自体がポジトラしてモデルなんかを表示できるので、これをVRモードのカメラの位置に転化してあげる、という流れです。

実際に動かしてみた動画です↓

ひとまず、単純に空間を認識して平面などを配置、カメラを移動する、いわゆる「AR」の実装方法を解説したあと、VRモードへの転用を説明します。
(まぁといっても、ほぼAR空間での処理が実装できれば、あとはカメラの位置同期を別のものに置き換えるだけなので大した問題ではありませんが)

単純にARコンテンツをさっと作るだけなら、そもそもUnityが専用のコンポーネントをすでに用意してくれているので、それを組み合わせるだけですぐにでも空間にモデルなんかを配置することができます。
今回の解説は、VRに転用するにあたって、動作の仕組みなんかを把握したかったので、基本クラスを使いつつ、自前で実装するにはどうするか、という視点でのまとめです。
(とはいえ、ネイティブのARKitとの通信はほとんどUnity側でやってくれてしまうので、あまりむずかしいことはやりませんが)

準備

アセットのインポート

UnityのアセットストアですでにUnity ARKit Pluginが公式に配布されているのでそれをダウンロード、インポートします。

Bitbucketから最新のものが取得できるようなので、新しい機能なりを試したい場合は見てみるといいかも。

インポートが終わったら準備完了です。
ネイティブのARKitとの通信はすべてUnityのコンポーネントが行ってくれるので、それを使って構築していきます。

既存のコンポーネントを利用する

今回は、ネイティブからのデータを使って処理を行うように実装していますが、ARの機能をさっと試したいだけであれば、既存のコンポーネントを組み合わせるだけですぐにARの機能を使うことができるようになっています。

使うコンポーネント

使うコンポーネントUnityEngine.XR.iOS名前空間に定義されています。
余談ですが、Android向けビルドでVR SDKをDaydreamにすると、Unity2017以降だとXRという名前になっていて、ARCoreのチェックボックなんかも出てくるので、今後はVR x ARのポジトラは標準になりそうな予感がしますね。

使うコンポーネントは以下になります。

  • UnityARVideo
  • UnityARCameraNearFar
  • UnityARCameraManager
  • UnityARHitTestExample
  • UnityARSessionNativeInterface

UnityARVideo

iOSバイスのカメラの映像を、CommandBuffer経由で描画するようです。
カメラ自体にAddComponentして使います。
また、インスペクタにはマテリアルを設定するようになっていますが、ARKitアセットに含まれているYUVMaterialを設定してあげればOKです。

なお、カメラの映像を出力しないVRモードであっても、このコンポーネントがないとARカメラの位置トラッキングがおかしくなっていたので、もしかしたらARとしての画像解析がこのクラス経由で行われているのかもしれません。

UnityARCameraNearFar

ARカメラのNearとFarを適切に設定するコンポーネント・・・のようですが、これがないとなにがダメなのかはちょっとまだ分かっていません( ;´Д`)
このコンポーネントも、メインのカメラにAddComponentして使います。

UnityARCameraManager

カメラの動きを制御するコンポーネント。マネージャという名前の通り、これは、カメラにAddComponentするのではなく、空オブジェクトなどに設定して、インスペクタからカメラオブジェクトを登録する形で使います。

内部的な処理としては、後述するUnityARSessionNativeInterfaceクラスから、ARKitの解析データを受け取り、適切にカメラの位置をARで認識した空間に基いて移動する仕組みを提供します。

UnityARHitTestExample

画面をタッチした際に、タッチ先に平面が認識されていたらそこに3Dモデルなどを移動してくれるサンプル用コンポーネント
Hit testのやり方などが記述されているので、タッチに反応するアプリを作る場合などは参考にするとよさそうです。

UnityARSessionNativeInterface

ネイティブのARKitからの情報を受け取る最重要クラス。
基本的に、上記のような機能を自前で実装する場合はこのクラスからの値を適切に使う必要があります。

以上のコンポーネントを連携させるだけで、ARKitの機能を使った簡単なモデル配置などはすぐに行うことができます。

このコンポーネント群については、以下の記事を参考にさせていただきました。

qiita.com

ARKitの機能を使う

さて、上記のコンポーネントを使うことで簡単なモックならすぐ作れてしまうでしょう。
ここからは、それらのコンポーネントが行ってくれている部分を少し紐解きながら、ARを使ったコンテンツを作る上で必要になりそうな部分を個別に解説していきたいと思います。

ARKitで認識した位置をUnityのカメラと同期する

ARコンテンツをAR足らしめているのが、この「カメラの移動」でしょう。
空間を認識し、それに基いてカメラが適切に動いてくれることで、3Dオブジェクトなどが本当にそこにあるかのように見せることができるわけです。

UnityARCameraManagerを参考に、必要な部分だけ抜き出す

さて、先ほども紹介したUnityARCameraManagerには、このカメラの位置を同期する処理が書かれています。
といっても、内部的な処理はほぼUnityARSessionNativeInterfaceがやってくれるので、毎フレーム、現在の姿勢をカメラに適用するだけでOKです。

private void Start()
{
    _session = UnityARSessionNativeInterface.GetARSessionNativeInterface();

    ARKitWorldTrackingSessionConfiguration config = new ARKitWorldTrackingSessionConfiguration();
    config.planeDetection = UnityARPlaneDetection.Horizontal; // 現状は`None`か`Horizontal`しか選べない
    config.alignment = UnityARAlignment.UnityARAlignmentGravity;
    config.getPointCloudData = true;
    config.enableLightEstimation = true;
    _session.RunWithConfig(config);
}

private void Update()
{
    Matrix4x4 matrix = _session.GetCameraPose();
    _arCamera.transform.localPosition = UnityARMatrixOps.GetPosition(matrix);
    _arCamera.transform.localRotation = UnityARMatrixOps.GetRotation(matrix);
    _arCamera.projectionMatrix = _session.GetCameraProjection();
}

大雑把に説明すると、ARKitの動作のConfigを作成し、それを元にセッションを開始、以後はそのセッションから得られるカメラの位置や回転を、そのままカメラのlocalPositionlocalRotationに適用してやるだけです。
こうすることで、ARのカメラとして適切に移動、回転が行われます。

UnityARAnchorManagerを元に、平面の位置のトラッキングを行う部分を抜き出す

オブジェクトを配置して、カメラの移動が行われれば、基本的にはARらしい見た目を表現することは可能です。
次は、ARKitのシステムが認識した平面の情報を使って、実際に空間に平面情報を表示する方法を見てみます。

private Dictionary<string, ARPlaneAnchorGameObject> planeAnchorMap;


private void Start()
{
    planeAnchorMap = new Dictionary<string,ARPlaneAnchorGameObject> ();

    UnityARSessionNativeInterface.ARAnchorAddedEvent += AddAnchor;
    UnityARSessionNativeInterface.ARAnchorUpdatedEvent += UpdateAnchor;
    UnityARSessionNativeInterface.ARAnchorRemovedEvent += RemoveAnchor;
}

上記の3つのイベントが、ARKitのシステムから発行されます。
それぞれ、アンカー(平面)が認識された、更新された、破棄されたタイミングで呼ばれます。

そのイベント内でどんな処理が書かれているのか見てみましょう。

ARAnchorAddedEvent

まずは、平面が認識された際のハンドラ内での処理です。

public void AddAnchor(ARPlaneAnchor arPlaneAnchor)
{
    GameObject go = UnityARUtility.CreatePlaneInScene (arPlaneAnchor);
    go.AddComponent<DontDestroyOnLoad> ();  //this is so these GOs persist across scene loads
    ARPlaneAnchorGameObject arpag = new ARPlaneAnchorGameObject ();
    arpag.planeAnchor = arPlaneAnchor;
    arpag.gameObject = go;
    planeAnchorMap.Add (arPlaneAnchor.identifier, arpag);
}

平面が認識された際の処理は、まず、UnityARUtilityクラスのユーティリティを使って平面オブジェクトを生成します。
そして、ARPlaneAnchorGameObjectクラスのインスタンスを生成し、それぞれ、GameObjectARPlaneAnchorへの参照をセットにして保持します。

あとはそれを、マネージャクラス自身が持っているDictionaryに登録しておきます。
これを登録する理由は、平面の情報は、認識後に連続した状態を持つため(※)更新時に、identifierを元に処理を行う必要があるためです。

※ 連続した情報というのは、どうやらARKitが認識した平面は一意なIDが振られ、その平面の状態がどうなったか、という連続的な計算になるようです。
そのため、検知時のIDをキーにして登録し、更新があった場合に、それを元に平面の位置などを変更してやる必要があるのです。

ARAnchorUpdatedEvent

次に更新処理。
上記でも書きましたが、更新処理は、「平面の連続性」故に、検知時のIDを利用して更新処理を行います。

public void UpdateAnchor(ARPlaneAnchor arPlaneAnchor)
{
    if (planeAnchorMap.ContainsKey (arPlaneAnchor.identifier)) {
        ARPlaneAnchorGameObject arpag = planeAnchorMap [arPlaneAnchor.identifier];
        UnityARUtility.UpdatePlaneWithAnchorTransform (arpag.gameObject, arPlaneAnchor);
        arpag.planeAnchor = arPlaneAnchor;
        planeAnchorMap [arPlaneAnchor.identifier] = arpag;
    }
}

Dictionaryに登録のある平面だった場合に、その状態を更新する処理となります。

ARAnchorRemovedEvent

最後に、平面が破棄されたときの処理。
こちらはたんに、Dictionary内にあったらその情報を削除しているだけですね。

public void RemoveAnchor(ARPlaneAnchor arPlaneAnchor)
{
    if (planeAnchorMap.ContainsKey (arPlaneAnchor.identifier)) {
        ARPlaneAnchorGameObject arpag = planeAnchorMap [arPlaneAnchor.identifier];
        GameObject.Destroy (arpag.gameObject);
        planeAnchorMap.Remove (arPlaneAnchor.identifier);
    }
}

平面に対する処理は以上です。
あとは、ARKit側で検知、更新、破棄が起こるタイミングで平面情報が更新されていきます。

画面をタップした際に、その位置の平面にオブジェクトを移動させる

ARでモデルを表示するだけでもだいぶ楽しい体験ができますが、やはりタップしたりしてインタラクティブなことができるとより楽しくなります。

private void Update()
{
    // 中略。Touchの確認処理
    
    var screenPosition = Camera.main.ScreenToViewportPoint(touch.position);
    ARPoint point = new ARPoint {
        x = screenPosition.x,
        y = screenPosition.y
    };

    // prioritize reults types
    ARHitTestResultType[] resultTypes = {
        ARHitTestResultType.ARHitTestResultTypeExistingPlaneUsingExtent, 
        // if you want to use infinite planes use this:
        //ARHitTestResultType.ARHitTestResultTypeExistingPlane,
        ARHitTestResultType.ARHitTestResultTypeHorizontalPlane, 
        ARHitTestResultType.ARHitTestResultTypeFeaturePoint
    }; 

    foreach (ARHitTestResultType resultType in resultTypes)
    {
        if (HitTestWithResultType (point, resultType))
        {
            return;
        }
    }
}

まずは、Updateメソッド内での処理です。
基本的なタッチ判定処理後だけを抜き出しています。

画面のタッチされた位置をViewport座標に変換したのち、ARpointクラスに値を設定します。
そして、検知したいResultTypeの配列を作り、順次、そのタイプに応じてタッチ位置との判定を行います。
判定は同クラスに設定されたHitTestWithResultTypeで行います。

bool HitTestWithResultType (ARPoint point, ARHitTestResultType resultTypes)
{
    List<ARHitTestResult> hitResults = UnityARSessionNativeInterface.GetARSessionNativeInterface ().HitTest (point, resultTypes);
    if (hitResults.Count > 0) {
        foreach (var hitResult in hitResults) {
            Debug.Log ("Got hit!");
            m_HitTransform.position = UnityARMatrixOps.GetPosition (hitResult.worldTransform);
            m_HitTransform.rotation = UnityARMatrixOps.GetRotation (hitResult.worldTransform);
            Debug.Log (string.Format ("x:{0:0.######} y:{1:0.######} z:{2:0.######}", m_HitTransform.position.x, m_HitTransform.position.y, m_HitTransform.position.z));
            return true;
        }
    }
    return false;
}

UnityARSessionNativeInterfaceHitTestメソッドが定義されているので、それを用いてヒットテストを行っています。
もしヒットした平面があった場合はヒット結果が1以上にあるため、それを元に分岐処理を行い、ヒットしたらその情報を出力しています。

なお、このクラスではタッチ位置にオブジェクトを移動する処理が含まれているので、同時に、設定されたオブジェクトの位置を変更する記述が見られます。

ARカメラを使ってポジトラする

さて最後に。
今回のAR関連の機能を使って、VRでのポジトラをする方法を説明します。
といっても、今までの処理を少し変えるだけなので、実装自体は大したことはしません。

まずはざっとコードを見てもらったほうが早いでしょう。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.XR.iOS;

public class ARAnchorUpdater : MonoBehaviour
{
    [SerializeField]
    private Transform _target;

    [SerializeField]
    private Camera _arCamera;

    [Header("---- AR Config Options ----")]
    [SerializeField]
    private UnityARAlignment _startAlignment = UnityARAlignment.UnityARAlignmentGravity;

    [SerializeField]
    private UnityARPlaneDetection _planeDetection = UnityARPlaneDetection.Horizontal;

    [SerializeField]
    private bool _getPointCloud = true;

    [SerializeField]
    private bool _enableLightEstimation = true;

    private Dictionary<string, ARPlaneAnchorGameObject> _planeAnchorMap = new Dictionary<string, ARPlaneAnchorGameObject>();

    private UnityARSessionNativeInterface _session;

    private void Start()
    {
        _session = UnityARSessionNativeInterface.GetARSessionNativeInterface();

        Application.targetFrameRate = 60;
        ARKitWorldTrackingSessionConfiguration config = new ARKitWorldTrackingSessionConfiguration();
        config.planeDetection = _planeDetection;
        config.alignment = _startAlignment;
        config.getPointCloudData = _getPointCloud;
        config.enableLightEstimation = _enableLightEstimation;
        _session.RunWithConfig(config);
    }

    private void Update()
    {
        // セッションからカメラの情報をもらう
        Matrix4x4 matrix = _session.GetCameraPose();

        _target.transform.localPosition = UnityARMatrixOps.GetPosition(matrix);

        // VRカメラでジャイロを使って回転するため、ここでは回転を適用しない
        //_target.transform.localRotation = UnityARMatrixOps.GetRotation(matrix);

        // ARカメラのプロジェクションマトリクスを更新
        // TODO: もしかしたらいらないかも?
        _arCamera.projectionMatrix = _session.GetCameraProjection();
    }
}

さて、見てもらうと分かりますが、上で書いたUnityARCameraManagerの中身を少しカスタマイズしただけですね。

違う点は、_targetに、ARカメラの移動を適用するためのオブエジェクトを、インスペクタから設定しているだけです。
Updateメソッド内を見てもらうと、カメラの位置同期の処理が_targetに対して行われているのが分かりますね。

そしてもうひとつ注意点として、「回転は適用しない」ということ。
なぜかというと、_targetに指定しているオブジェクトの子要素に、VRカメラが存在しているためです。
そしてVRカメラはCardboard SDKが、ジャイロを使って自動的に回転処理をしてくれます。

つまり、ARカメラの回転も伝えてしまうと、回転が二重にかかってしまうわけなんですね。
(最初それに気づかず、なんで180°回転しただけなのに一周しちゃうんだろうとプチハマりしてました・・)

なので位置だけを同期してあげればいいわけです。
まさに「ポジトラだけ」ARカメラからもらっている感じですね。

以上で、モバイルVRでもポジトラができるようになります。

その他、参考にした記事

lilea.net

recruit.gmo.jp

視錐台とAABBとの交差判定

概要

とあるオブジェクトが「カメラに映る対象か」というのを知りたいケースはあると思います。
また、通常のカメラだけでなく、「視界」を表した視錐台を定義してその中にオブジェクトが含まれるか、というのもあるとありがたい機能でしょう。
(例えば敵AIの視界表現とか)

実際に動かした感じはこんなふうになります↓

ただ、ツイートにも書いてますが視錐台を視覚化するGizmosメソッドがありますが、微妙にカメラのそれと違うのが気になりました・・。
気づいた点として、OnDrawGizmosのときのカメラのAspectが、プレイ時のカメラのAspectと違う、というのはありました。

今回の記事、実装は以下の記事を参考にさせてもらいました。

実装したサンプルはGithubにアップしてあります。

考え方

平面には「表面」と「裏面」が存在します。
平面は、平面の位置から表と裏のふたつの空間に分離している、と見ることもできますね。

ここで、「表側」を平面の法線が向いている方向、「裏側」を平面の法線の反対側とし、AABB(Axis-Aligned Bounding Box:軸並行境界ボックス)の8つある頂点のうち、表側の方向にある点の中で一番端の点を「Positive Point(正の頂点)」、裏側の方向にある点の中で一番端にある点を「Negative Point(負の頂点)」とします。

そしてそれら2頂点の平面との距離を測り、その結果によって平面のどちら側にAABBが存在しているか(あるいは交差しているか)を判定します。

と、言葉だけでは分かりづらいと思うので、図にすると以下のようになります。

図解すると以下のようになります。

AABBの位置の判定方法

AABBの位置の判定方法ですが、意外にシンプルです。

まず、定義した正の頂点と平面との垂線の距離を測ります。
仮にその距離(内積結果)がマイナスだった場合、これは正の頂点が裏側にあることになります。

上記の図を見てもらうと一目瞭然ですが、正の頂点がそもそも裏側にある場合、AABBは必ず裏側にあることになります。

さて、正の頂点がプラス側にある場合、AABBは平面の表側に存在することが確定します。
しかし、状況として以下の2点が考えられます。

  • AABBが完全に表側にある
  • AABBが平面と交差している

もし交差を無視していいのであれば、この時点で判定は終わりになりますが、交差も求めたい場合はさらに計算を続けます。
といっても計算は正の頂点に行ったものと同じことを、負の頂点にも行うだけです。

もし、負の頂点と平面との垂線の距離がプラスだった場合は、AABBは完全に平面の表側にあります。
逆に、距離がマイナスだった場合は、負の頂点が平面の裏側にあることになるので、AABBは平面と交差していることになります。

AABBと平面との判定がこんなに簡単にできるのは驚きですね。

視錐台の内外を判定する

以上で、平面とAABBの交差判定が行えることが分かりました。
今回は、カメラの視錐台にオブジェクトが入っているかどうか、の判断を行いたいため、これだけでは終わりません。

といってもほぼ答えは出ている状態です。

つまり、視錐台は6つの平面で出来ている、と考えることができるので、この6平面との交差判定を行い、そのすべての平面と交差、あるいは表側にある、と判定されれば、それは視錐台の中にオブジェクトが含まれている、と考えることができるのです。

考え方は以上で終わりです。理論としてはとてもシンプルですね。
以下からは、それらの計算方法について詳しく見ていきたいと思います。

正・負の頂点を求める

AABBが持つ8頂点のうち、どの点が平面に対して正の頂点・負の頂点となるのか。
その取得には面の法線のみで決定することができます。

実際に実装した内容は以下のようになります。

/// 
/// 法線から一番近い点を算出する
/// 
/// ターゲットとなるAABB
/// 算出する法線
/// 
static private Vector3 GetPositivePoint(Collider target, Vector3 normal)
{
    Bounds bounds = target.bounds;
    Vector3 result = bounds.min;

    if (normal.x > 0)
    {
        result.x += bounds.size.x;
    }
    if (normal.y > 0)
    {
        result.y += bounds.size.y;
    }
    if (normal.z > 0)
    {
        result.z += bounds.size.z;
    }

    return result;
}

/// 
/// 法線から一番遠い点を算出する
/// 
/// ターゲットとなるAABB
/// 算出する法線
/// 
static private Vector3 GetNegativePoint(Collider target, Vector3 normal)
{
    Bounds bounds = target.bounds;
    Vector3 result = bounds.min;

    if (normal.x < 0)
    {
        result.x += bounds.size.x;
    }
    if (normal.y < 0)
    {
        result.y += bounds.size.y;
    }
    if (normal.z < 0)
    {
        result.z += bounds.size.z;
    }

    return result;
}

処理はとてもシンプルです。

渡された面の法線ベクトルの各成分のプラス・マイナスを見て、プラス(マイナス)側に属する点を算出しているだけです。
なので、法線ベクトルの各成分の0未満、0より上かの判定だけで点の位置を求めています。

なぜ法線だけで求まる?

なぜこれだけで点が求まるのか。
理由は、AABBは「座標に対してすべての辺が垂直・平行である」ということを考えれば分かります。

例えば、平面の法線の方向が上に向いている(Y軸の値がプラス)の場合、AABBの正の側の頂点は必ず上部にある点に限定されます。
あとはこれを、XZ軸に対してもそれぞれ行ってやれば、めでたく正・負の頂点が求まる、というわけです。

さぁ、ふたつの点が算出できたので、次は平面との距離の計算に進みましょう。

平面との距離を計算する

平面との交差判定のために、平面に対する垂線の距離が必要となります。
平面と頂点の垂線の距離は、平面の法線との内積を取ることで簡単に計算することができます。
具体的には、距離を測りたい点Aと、平面の位置を表す点Bとのベクトル「 \vec{AP}」と平面の法線「 \vec{N}」との内積の絶対値が垂線の長さとなります。

図にすると以下のようになります。

ただし、今回は「表」をプラス、「裏」をマイナスとするため絶対値ではなくそのまま結果を利用することで、表裏の判定も含めて距離を算出することができます。

視錐台の6平面の法線を求める

さて最後は、問題となる視錐台を構成する6平面の、各平面の法線の求め方です。
求め方は以下の記事がとても分かりやすく書かれています。

http://miffysora.wikidot.com/frustum-extract-plane

まず考え方として、判定したい点 vにProjectionMatrix(射影行列)を掛ます。

ProjectionMatrixを Pとし、判定したい頂点を vとすると、


Pv = v'

と書けます。

行列とベクトルの掛け算は行とベクトルとの内積を計算するのと同じことなので、以下のように書くことができます。


\begin{vmatrix}
x \cdot P\_{11} + y \cdot P\_{12} + z \cdot P\_{13} + w \cdot P\_{14} \\\
x \cdot P\_{21} + y \cdot P\_{22} + z \cdot P\_{23} + w \cdot P\_{24} \\\
x \cdot P\_{31} + y \cdot P\_{32} + z \cdot P\_{33} + w \cdot P\_{34} \\\
x \cdot P\_{41} + y \cdot P\_{42} + z \cdot P\_{43} + w \cdot P\_{44}
\end{vmatrix} =
\begin{vmatrix}
v \cdot row_1 \\\
v \cdot row_2 \\\
v \cdot row_3 \\\
v \cdot row_4
\end{vmatrix} =
\begin{vmatrix}
x' \\\
y' \\\
z' \\\
w'
\end{vmatrix}

このとき、変換された v'は「同次座標」と呼ばれ、これは「クリッピング座標系」となります。
 wで全要素( x, y, zを割ることで、クリッピング座標系は立方体となります)

参考: http://miffysora.wikidot.com/clip-coordinates

さてここで、以下の式を満たすとき、 xは視錐台の中に収まります。


-w' < x' < w'

つまり、すべての要素に対して不等式が成り立てば、頂点 vは視錐台内にある、と判定されます。


-w' < x' < w' \\\
-w' < y' < w' \\\
-w' < z' < w'

そしてそれぞれの不等式の意味は以下のようになります。


-w' < x' ... (1) \\\
x' < w' ... (2) \\\
-w' < y' ... (3) \\\
y' < w' ... (4) \\\
-w' < z' ... (5) \\\
z' < w' ... (6)
  • (1) ... x'は視錐台の左平面の内側
  • (2)... x'は視錐台の右平面の内側
  • (3) ... y'は視錐台の下平面の内側
  • (4)... y'は視錐台の上平面の内側
  • (5) ... z'は視錐台の近平面の内側
  • (6)... z'は視錐台の遠平面の内側

ここで、左平面に着目してみると、


-w' < x'

を満たすとき、点 xは左平面の「表側」にいることになります。

この式は以下から得られます。


\begin{vmatrix}
v \cdot row_1 \\\
v \cdot row_2 \\\
v \cdot row_3 \\\
v \cdot row_4
\end{vmatrix} =
\begin{vmatrix}
x' \\\
y' \\\
z' \\\
w'
\end{vmatrix}

-w' = -(v \cdot row_4)
x' = (v \cdot row_1)

として得られます。展開すると、


-(v \cdot row_4) < (v \cdot row_1)

となります。

さらに整理して、


0 < (v \cdot row_4) + (v \cdot row_1) \\\
0 < v \cdot (row_4 + row_1) 

となります。

 v (x, y, z, w)です。上記は、ベクトルのそれぞれの成分と、行列の成分( row_4 + row_1)を足したものの内積を取る、ということになります。

つまり、


x(m\_{41} + m\_{11}) + y(m\_{42} + m\_{12}) + z(m\_{43} + m\_{13}) + w(m\_{44} + m\_{14}) = 0

 wの値は常に1で消せるので、


x(m\_{41} + m\_{11}) + y(m\_{42} + m\_{12}) + z(m\_{43} + m\_{13}) + (m\_{44} + m\_{14}) = 0

となります。

 x成分に着目すると、 x * (m_{41} + m_{11})ということです)

さてここで、「平面の方程式」を思い出してみます。

mathtrain.jp

平面の方程式は


ax + by + cz + d = 0

です。
先程展開した式を見てみるとまさにこの形になっているのが分かるかと思います。

展開した式を平面の方程式に当てはめてみると、


a = (m\_{41} + m\_{11}) \\\
b = (m\_{42} + m\_{12}) \\\
c = (m\_{43} + m\_{13}) \\\
d = (m\_{44} + m\_{14})

と整理することが出来ます。

そして平面の方程式から、各 a, b, cは平面の法線になります。
(ただし正規化していないので使う際に正規化する必要あり)

あとは、それぞれの平面に対して上記を求めてやれば視錐台の6平面の法線が求まります。

平面 係数a 係数b 係数c 係数d
m41 + m11 m42 + m12 m43 + m13 m44 + m14
m41 - m11 m42 - m12 m43 - m13 m44 - m14
m41 + m21 m42 + m22 m43 + m23 m44 + m24
m41 - m21 m42 - m22 m43 - m23 m44 - m24
m41 + m31 m42 + m32 m43 + m33 m44 + m34
m41 - m31 m42 - m32 m43 - m33 m44 - m34

※ ただ、Unityの場合は上記の計算ではうまく行かなかったので、サンプルコードでは若干調整してあります。

いったん整理

ここまでで、以下の道具が揃いました。

  • 射影行列から視錐台の平面の法線の求め方
  • 点と平面の垂線の長さの計算
  • AABBの正・負の頂点位置の計算

これを元に計算を行えば、カメラのFov、Near、Far、そしてワールド座標位置から射影行列を計算し、さらにその行列から6平面を計算、それぞれの平面に対してAABBが内外どちらにあるかの判定、が行えるようになります。

ちなみに、射影行列の成分の意味についてはマルペケさんのこちらの記事(その70 完全ホワイトボックスなパースペクティブ射影変換行列)が非常に分かりやすいです。

これをゲームに組み込む場合は、毎フレームごとにこれを繰り返してやれば、冒頭の動画のように「視界に入っているか否か」を判定することができるようになります。

注意点として、これはあくまで「射影行列の視錐台の中に入っているか」という判定を行っているにすぎないので、もし視点と対象の間に遮蔽物があったとしても「内外判定」は「true」を返します。
実際のAIに組み込むなどする場合は、さらに視線と対象の間に遮蔽物がないか、の判定が必要になるでしょう。
(ただ、それは今回の解説の範疇外なので割愛します)

サンプルコード

最後に、今回実装したコードを載せておきます。

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

static public class CheckFrustum
{
    public enum State
    {
        Outside, Inside, Intersect,
    }

    /// 
    /// 対象AABBとProjection Matrixから視錐台内に入っているかの検知を行う
    /// 
    /// AABB対象
    /// Projection Matrix
    /// カメラ位置
    /// カメラのNear
    /// カメラのFar
    /// 
    static public State Detect(Collider target, Matrix4x4 pmat, Transform eyeTrans, float near, float far)
    {
        Plane[] planes = CalculateFrustumPlanes(pmat, eyeTrans, near, far);

        State result = State.Inside;

        for (int i = 0; i < planes.Length; i++)
        {
            Vector3 normal = planes[i].normal;
            Vector3 vp = GetPositivePoint(target, normal);
            Vector3 vn = GetNegativePoint(target, normal);

            // (vp - plane.pos)・normal
            float dp = planes[i].GetDistanceToPoint(vp);
            if (dp < 0)
            {
                return State.Outside;
            }

            float dn = planes[i].GetDistanceToPoint(vn);
            if (dn < 0)
            {
                result = State.Intersect;
            }
        }

        return result;
    }

    /// 
    /// 法線から一番近い点を算出する
    /// 
    /// ターゲットとなるAABB
    /// 算出する法線
    /// 
    static private Vector3 GetPositivePoint(Collider target, Vector3 normal)
    {
        Bounds bounds = target.bounds;
        Vector3 result = bounds.min;

        if (normal.x > 0)
        {
            result.x += bounds.size.x;
        }
        if (normal.y > 0)
        {
            result.y += bounds.size.y;
        }
        if (normal.z > 0)
        {
            result.z += bounds.size.z;
        }

        return result;
    }

    /// 
    /// 法線から一番遠い点を算出する
    /// 
    /// ターゲットとなるAABB
    /// 算出する法線
    /// 
    static private Vector3 GetNegativePoint(Collider target, Vector3 normal)
    {
        Bounds bounds = target.bounds;
        Vector3 result = bounds.min;

        if (normal.x < 0)
        {
            result.x += bounds.size.x;
        }
        if (normal.y < 0)
        {
            result.y += bounds.size.y;
        }
        if (normal.z < 0)
        {
            result.z += bounds.size.z;
        }

        return result;
    }

    /// 
    /// 指定されたProjection Matricsから視錐台の6面の平面を求める
    /// 
    /// Projection Matrix
    /// カメラ位置
    /// カメラのNear
    /// カメラのFar
    /// 
    static public Plane[] CalculateFrustumPlanes(Matrix4x4 pmat, Transform eyeTrans, float near, float far)
    {
        Plane[] result = new Plane[6];

        // 0: Left, 1: Right, 2: Bottm, 3: Top
        for (int i = 0; i < 4; i++)
        {
            float a, b, c, d;
            int r = i / 2;
            if (i % 2 == 0)
            {
                // 平面の方程式
                // ax + by + cz + d = 0
                a = pmat[3, 0] - pmat[r, 0];
                b = pmat[3, 1] - pmat[r, 1];
                c = pmat[3, 2] - pmat[r, 2];
                d = pmat[3, 3] - pmat[r, 3];
            }
            else
            {
                a = pmat[3, 0] + pmat[r, 0];
                b = pmat[3, 1] + pmat[r, 1];
                c = pmat[3, 2] + pmat[r, 2];
                d = pmat[3, 3] + pmat[r, 3];
            }

            Vector3 normal = -new Vector3(a, b, c).normalized;
            normal = eyeTrans.rotation * normal;

            result[i] = new Plane(normal, eyeTrans.position);
        }

        // for the near plane
        {
            float a = pmat[3, 0] + pmat[2, 0];
            float b = pmat[3, 1] + pmat[2, 1];
            float c = pmat[3, 2] + pmat[2, 2];
            float d = pmat[3, 3] + pmat[2, 3];

            Vector3 normal = -new Vector3(a, b, c).normalized;
            normal = eyeTrans.rotation * normal;

            Vector3 pos = eyeTrans.position + (eyeTrans.forward * near);
            result[4] = new Plane(normal, pos);
        }

        // for the far plane
        {
            float a = pmat[3, 0] - pmat[2, 0];
            float b = pmat[3, 1] - pmat[2, 1];
            float c = pmat[3, 2] - pmat[2, 2];
            float d = pmat[3, 3] - pmat[2, 3];

            Vector3 normal = -new Vector3(a, b, c).normalized;
            normal = eyeTrans.rotation * normal;

            Vector3 pos = eyeTrans.position + (eyeTrans.forward * near) + (eyeTrans.forward * far);
            result[5] = new Plane(normal, pos);
        }

        return result;
    }
}

UnityのAPIを使う

ちなみに、カメラ自体を使う場合はUnityに標準で同等の処理をしてくれるユーティリティがあるので、そちらを使うほうが手っ取り早いでしょう。

// 視錐台の6平面を取得
Plane[] planes = GeometryUtility.CalculateFrustumPlanes(Camera.main);
 
// 内外判定
if (GeometryUtility.TestPlanesAABB(planes, bounds))
{
    // 含まれていたときの処理
}
else
{
    // 含まれていなかったときの処理
}

参考にさせてもらった記事: 【Unity】【数学】視錐台(Frustum)について(第2回) – 株式会社ロジカルビート

docs.unity3d.com

ComputeShaderを触ってみる その2 ~バッファ・テクスチャ編~

概要

前回の記事(ComputeShaderを触ってみる その1 ~スレッド編~)で、Compute Shaderのスレッドの概念について書きました。

edom18.hateblo.jp

今回は、Compute Shaderを実際に使って、少し意味のある計算をしてみたいと思います。
意味のある計算をさせるためには当然、CPU側との連携が必要になるので、そのあたりを中心に書いていきたいと思います。

ちなみに今回実装したのは、とある案件で実際に使うことにしたのでそれを元にしたメモです。
具体的には、ひとつのテクスチャを渡すと、決められたブロック単位に切り分け、そのブロック内の透明度を判断、不透明と判断されたところを有効、それ以外を無効としてマークする、というものです。

実際に実行したイメージ図は以下のような感じです。

f:id:edo_m18:20171004110055p:plain

左の画像が渡したテクスチャで、黒い部分が透明なところです。
右の絵がそれを元に計算した、ざっくりと「不透明な部分」を青く色づけしたものです。

なんとなく、不透明な部分と青い部分が一致しているのが分かるかと思います。

まぁぶっちゃけ、CPUでやってもまぁ問題にならないレベルの処理だと思いますが、Compute Shaderを使ういい練習にはなるかなとw

ComputeShaderに値を渡す

さて、コンピュートシェーダで大事なスレッドの概念を説明したあとは、シェーダに対してCPU側から値を送る方法を見てみましょう。

設定についてはSetXXXX系メソッドを使います。

例えば、特定のfloat値を渡したい場合は以下のように記述します。

シェーダ側

float myFloat;

スクリプト

shader.SetFloat("myFloat", 1.0f);

intを渡したい場合は、

shader.SetInt("myInt", 5);

などのようにしてやれば大丈夫です。

CPUとGPUでは基本的にはメモリ空間が異なるため(物理的にも離れているケースがだいたい。のはず)、CPUで使っているデータを、GPUで使えるメモリにコピーする(転送する)必要があります。
そのため、上記のように、GPU側に「これからこのデータをこういう変数名で送りますよ」という宣言をしているわけですね。

ちなみに、GPU周りのことについて詳しく知りたい方は、以下の書籍がオススメです。
最近のグラフィック事情から、GPUのハード・ソフト的な面の説明に、CPUとどうやって連携しているか、など細かい内容が詳細に書かれています。

GPUを支える技術 ――超並列ハードウェアの快進撃[技術基礎] (WEB+DB PRESS plus)

GPUを支える技術 ――超並列ハードウェアの快進撃[技術基礎] (WEB+DB PRESS plus)

閑話休題

基本的には、シェーダ側で使用したい変数を宣言しておき、SetXXXX系メソッドでCPUからデータを転送してやればOKなわけですね。

データを受け取るバッファ

さて、送るだけなら上記のように記述してやればOKですが、GPGPUということは、なにかしら計算した結果をCPU側で利用したいはずです。
そのため、GPUで計算した結果を受け取る方法が必要になります。

そのために利用されるのがComputeBufferクラスです。

まずは簡単に、使い方のコード断片を。

RWStructuredBuffer<float> Result;

シェーダ側ではRWStructuredBuffer<T>型のバッファを宣言しておきます。
ジェネリックで指定する型は、実際に利用したいデータの型です。

続いてCPU側。

ComputeBuffer buffer = new ComputeBuffer(num, sizeof(float));
int kernelID = _shader.FindKernel("CSMain");
_shader.SetBuffer(kernelID, "Result", buffer);

CPU側ではComputeBufferクラスのインスタンスを、これまたSetBufferでセットします。
ComputeBufferのコンストラクタに渡している第一引数はバッファの個数です。第二引数で1要素のサイズを指定します。

このあたりはC言語などを触ったことがある人であればイメージしやすいかと思います。

実際にデータを受け取る

さて、バッファをセットしただけでは当然、データは取得できません。
実際にデータを取得するには以下のようにします。

float[] data = new float[num];
buffer.GetData(data);

GPUに設定していたバッファのGetDataメソッドを利用して、GPUの計算結果をCPUに転送します。
受け取るために、バッファと同じサイズのデータをCPU側で確保して、その確保した領域にデータを転送してもらう感じですね。

あとは、dataをいつものようにfor文などでループさせて、目的の処理を行います。

バッファの後処理

普段、C#を触っているとGCでメモリを開放してくれるのであまり気にすることはないかもしれませんが、今回利用したバッファは自身で適切に解放してやらないとなりません。

buffer.Release();

Releaseメソッドを呼ぶことで適切にメモリが開放されます。

このあたりは、C言語C++を触っている人ならそこまで気にならないかもしれませんね。

テクスチャのread / write

次はテクスチャへのアクセスについてです。
本来、GPUはグラフィクス周りを担当するハードウェアなので、当然ながらテクスチャへのアクセスも行えます。

GPU側では以下のように宣言します。

RWTexture2D<float4> texCopy;
Texture2D<float4> tex;

さて、RWと付いたものと付いていないものがあります。
これは、(多分)Read / Writeの略だと思いますが、つまり、読み込み専用か、書き込みも行えるか、の違いです。
そして上記のtexCopy変数のほうは読み書きが行なえます。

前回の記事のコードを再掲すると、

#pragma kernel CSMain

RWTexture2D<float4> texCopy;
Texture2D<float4> tex;

[numthreads(8,8,1)]
void CSMain(uint2 id : SV_DispatchThreadID)
{
    float4 t = tex[id];
    texCopy[id] = t;
}

これはただ、渡されたテクスチャの値をコピーするだけの簡単なサンプルです。
ここで重要な点は、テクスチャの各要素へのアクセスが非常に簡単だ、という点です。

上記の例では分かりやすさのために、一度テクセルを変数に入れたのちに、コピー対象のテクスチャに入れていますが、それを省略すれば一行で記述できてしまうほどの簡単さです。
テクスチャは2次元の配列になっていて、添字にはuint2型の値を使うことができます。
上記の例ではiduint2型なので、そのままアクセスできている、というわけですね。

そしてなぜそれが適切に各テクセルにアクセスできるのか、については前回の記事を参照してください。

テクスチャの内容を、畳込み処理で透明・不透明を判断する

以上で、バッファ、変数周りの記述とその意味についての解説が終わりました。
最後は、これらをまとめて、少し意味のある計算を行う例を解説したいと思います。

冒頭でも書いたように、今回は、テクスチャに対して指定したブロック単位に区切り、そのブロックの中のテクセルの平均が、「透明」に属するのか「不透明」に属するのかの計算をしてみたいと思います。

まずは今回書いたシェーダコードを載せます。

#pragma kernel CSMain

RWStructuredBuffer<float> Result;
Texture2D<float4> Texture;

int Length;
int Width;
int Height;

[numthreads(1, 1, 1)]
void CSMain (uint2 id : SV_DispatchThreadID)
{
    float result = 0;
    int halfWidth = Width * 0.5;
    int halfHeight = Height * 0.5;

    // 指定された分の、縦横の透明度を合計する
    for (int i = -halfHeight; i <= halfHeight; i++)
    {
        for (int j = -halfWidth; j <= halfWidth; j++)
        {
            int u = (id.x * Width) + halfWidth + j;
            int v = (id.y * Height) + halfHeight + i;
            float4 tex = Texture[uint2(u, v)];
            result += tex.a;
        }
    }

    float denom = 1.0 / (Width * Height);
    int index = id.y * Length + id.x;
    Result[index] = result * denom;
}

とても短いコードですね。

まず、なにをしているかを図解します。

今回、CPU側では以下のようにComputeShaderを起動しています。

_shader.Dispatch(kernelID, divCount, divCount, 1);

つまり、縦横に同じ数だけ分割したスレッドグループを起動しています。
なので図にすると以下のようなブロック分、スレッドグループが起動されるわけですね。

f:id:edo_m18:20171007165123p:plain

今回は10 x 10分割したので、全部で100個のブロックがあることになります。
そして、各ブロックに含まれるピクセルすべての透明度を足しこみ、最後に全体のピクセル数で割ることで、そのブロックの平均透明度を求めている、というわけです。

利用する際は、その平均値を元に、しきい値以上あれば不透明扱い、それ以下なら透明扱いと判断することで、冒頭の画像のように、透明・不透明の判定を行っている、というわけですね。

CPU側は以下のように起動しています。

_shader = ComputeShader.Instantiate(Resources.Load<ComputeShader>("Shaders/HitAreaDetector"));

// 分割数で算出された一区画に対するピクセル数をさらに奇数に補正する
int pixelPerDivW = texture.width / divCount;
pixelPerDivW = pixelPerDivW - (1 - pixelPerDivW % 2);

int pixelPerDivH = texture.height / divCount;
pixelPerDivH = pixelPerDivH - (1 - pixelPerDivH % 2);

int num = divCount * divCount;
ComputeBuffer buffer = new ComputeBuffer(num, sizeof(float));

int kernelID = _shader.FindKernel("CSMain");

_shader.SetBuffer(kernelID, "Result", buffer);
_shader.SetTexture(kernelID, "Texture", texture);
_shader.SetInt("Length", divCount);
_shader.SetInt("Width", pixelPerDivW);
_shader.SetInt("Height", pixelPerDivH);

float[] rawData = new float[num];

_shader.Dispatch(kernelID, divCount, divCount, 1);
buffer.GetData(rawData);

buffer.Release();

やっていることはシンプルに、ひとブロック分のピクセル数を計算し、GPUへは、計算対象となるテクスチャと、結果を受け取るバッファ、そしてピクセル数などのパラメータを送っているのみです。
あとは、計算結果を取得して必要なデータとして処理を行う、という流れです。

ハマった点

最後に、いくつかハマった点を。

今回、テクスチャをブロックに分割してあたりを付ける、という処理を書きましたが、ComputeShaderの仕様なのか、ひとつのシェーダを複数個同時起動すると、バッファへ値が正常に格納されず、意図した動作にならない、という挙動になりました。
シェーダ自体を複製すると問題が解決するので、ロードしたシェーダを複製したところ、以下のエラーが・・・。

!(transfer.IsRemapPPtrTransfer() && transfer.IsReadingPPtr())
UnityEngine.Object:Instantiate(ComputeShader)

どうやらUnityのバグ?らしく、以下の投稿を見つけました。

https://forum.unity.com/threads/error-when-instantiating-compute-shader.408533/

ただ、エラーは出るものの、意図した挙動になっているので、いったんはこのエラーに目をつぶって実装を進めました・・。

このあたりについて、なにか情報をお持ちの方は連絡いただけるとうれしいです( ;´Д`)