読者です 読者をやめる 読者になる 読者になる

e.blog

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

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

C# Unity 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を切り替える

C# Unity Editor

概要

やってることはただの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 C# Unity デバッグ

概要

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;
        }

    }
}

VRモードをオフにして起動するバッチファイルを作る

C# Editor Unity VR

概要

VRコンテンツのネットワーク対応をしていると、ビルドしてexeファイルとエディタのふたつでデバッグするときがあるんですが、VRモードを双方ともオンにしているとあとから起動したほうが終了されてしまいます。
HMDはひとつのアプリでしか使えない)

ただ、毎回VRサポートをオフにしたりオンにしたり、とかしているのはだいぶめんどくさいので、なんとかできないかなと調べました。
結論から言うと、コマンドライン引数でオフにすることができたのでそれを簡単にするためのバッチファイル作成用スクリプトを書きました。(大層なもんじゃないけど)

VRモードをオフにする

Unityから書き出したexeファイルに、コマンドライン引数で-vrmodeを指定するとどのVRモードかを選択することができます。
ここで、-vrmode Noneを与えるとVRモードをオフにすることができます。
(これを利用するには多分、Build settingsのVR SupportedでNoneを追加しておく必要があるはず)

バッチファイルを自動生成する

さて、コマンドライン引数で与えてVRモードをオフにすることができるのが分かりましたが、毎回それを実行するのもそれはそれで面倒です。
あくまでデバッグ目的のものなので、簡単なバッチファイルを生成して簡単にしておくといいかなと思ったので、そのスクリプトです。

コード

using UnityEditor;
using UnityEditor.Callbacks;
using System.Collections;
using System.IO;

public static class CreateBatchFile
{
    [PostProcessBuild(100)]
    public static void OnPostProcessBuild(BuildTarget target, string path)
    {
        string directory = Path.GetDirectoryName(path);
        string filePath = Path.Combine(directory, "launchWithoutHMD.bat");

        string exeName = Path.GetFileName(path);
        using (StreamWriter writer = File.CreateText(filePath))
        {
            writer.Write("start ./" + exeName + " -vrmode None\nexit /B");
        }
    }
}

こんな感じでPostProcess用のスクリプトを書いて、Editorフォルダに突っ込んでおけば自動的にバッチファイルを生成してくれます。

しかし、VR開発していてネットワーク対応させようとすると1台のPCでやるのはだいぶきびしい感じですね( ;´Д`)
(VR ReadyなノートPCがほしい・・)

カスタムエディタを使ってシーン内にハンドルを表示する

Unity C# Editor

概要

スクリプトをアタッチして、インスペクタからVector3型の値を操作する、というケースはままあるでしょう。
インスペクタから設定する場合、XYZそれぞれの値を手入力で入力して設定していくことになりますが、位置の調整だったり、回転だったり、といったものに利用する値の場合、どうしても細かい数値で調整する必要があります。

すると問題になるのが、「微調整のやりづらさ」。
しかも位置合わせの目的でVector3を用いている場合は、いちいち実行したりして確認する必要があってとてもめんどくさい作業になりがちです。

今回はそんなケースで利用できるカスタムエディタの仕組みについて書きます。

f:id:edo_m18:20161130134028p:plain
↑左の小さいCubeに見えるものはカスタムエディタの機能で、位置を分かりやすく表示しているだけで、GameObjectではありません。

f:id:edo_m18:20161130134031p:plain
↑インスペクタの表記。PositionとRotationを設定する、という想定。

位置と回転を視覚的に表現する

カスタムエディタの中で、以下の機能を利用することで画像のような機能を提供することができるようになります。

  • Handles.PositionHandle
  • Handles.RotationHandle

利用するには以下のように記述します。

var newPos = Handles.PositionHandle(pos, rot);
var posChanges = EditorGUI.EndChangeCheck();

ちなみに上記の処理はOnSceneGUIメソッド内で実行します。

コード自体はシンプルなので、すべて見てもらったほうが分かりやすいと思います。

コード

using UnityEngine;
using System.Collections;

[ExecuteInEditMode]
public class ViewTest : MonoBehaviour
{
    [SerializeField]
    Vector3 _position;

    [SerializeField]
    Vector3 _rotation;

    public Vector3 Position{ get { return _position; } set { _position = value; } }
    public Quaternion Rotation { get { return Quaternion.Euler(_rotation); } set { _rotation = value.eulerAngles; } }
}
using UnityEngine;
using UnityEditor;
using System.Collections;

[CustomEditor(typeof(ViewTest))]
public class ViewTestEditor : Editor
{
    protected virtual void OnSceneGUI()
    {
        var script = target as ViewTest;

        var pos = script.Position;
        var rot = script.Rotation;

        EditorGUI.BeginChangeCheck();
        var newPos = Handles.PositionHandle(pos, rot);
        var posChanges = EditorGUI.EndChangeCheck();

        EditorGUI.BeginChangeCheck();
        var newRot = Handles.RotationHandle(rot, pos);
        var rotChanges = EditorGUI.EndChangeCheck();

        Handles.CubeCap(0, pos, rot, 0.2f);

        if (posChanges || rotChanges)
        {
            if (posChanges)
            {
                script.Position = newPos;
            }
            if (rotChanges)
            {
                script.Rotation = newRot;
            }
        }
    }
}

Standard Assetsの「GlassStainedBumpDistort」シェーダを覗いてみた

Unity シェーダ

概要

「Standard Assets」に含まれている「GlassStainedBumpDistort」を覗いてみました。
どういうシェーダかというと、以下のように、オブジェクトの背面を歪ませる効果を実現するものです。

f:id:edo_m18:20161128210446p:plain

準備するのは歪ませるための法線を持たせたBumpMap用のテクスチャだけなので比較的簡単に利用できます。
(実装もそんなに複雑ではないので色々参考になりそう)

頂点シェーダ

まずは頂点シェーダ。

v2f vert (appdata_t v)
{
    v2f o;
    o.vertex = mul(UNITY_MATRIX_MVP, v.vertex);
    #if UNITY_UV_STARTS_AT_TOP
    float scale = -1.0;
    #else
    float scale = 1.0;
    #endif
    o.uvgrab.xy = (float2(o.vertex.x, o.vertex.y*scale) + o.vertex.w) * 0.5;
    o.uvgrab.zw = o.vertex.zw;
    o.uvbump = TRANSFORM_TEX( v.texcoord, _BumpMap );
    o.uvmain = TRANSFORM_TEX( v.texcoord, _MainTex );
    UNITY_TRANSFER_FOG(o,o.vertex);
    return o;
}

UNITY_UV_STARTS_AT_TOPは、プラットフォームごとに異なるテクスチャ座標系を適切に取り扱うために利用します。
ドキュメントから引用すると以下になります。

Render Texture の座標

垂直方向のテクスチャ座標の表現方法は、Direct3DOpenGL のプラットフォームで異なります。

  • Direct3D、Metal、コンソールでは最上部が 0 の座標位置となり、下方向に行くにしたがって増加します。
  • OpenGLOpenGL ES では、最下部が 0 の座標位置となり、上方向に行くにしたがって増加します。

ほとんどの場合に影響はありませんが、レンダーテクスチャ に対してレンダリングする場合は影響があります。この場合、Unity は OpenGL 以外でレンダリングするときに意図的にレンダリングを上下逆に反転するので、プラットフォーム間のルールは同じままです。シェーダーで処理する必要のある一般的な例は、イメージエフェクトと、UV空間のレンダリングです。

docs.unity3d.com

テクスチャ座標を計算

このシェーダでは、前のパスでレンダリングされた結果をキャプチャし、それを利用して後ろを「透過させているように」見せているため、オブジェクトの後ろを表すテクスチャから色をフェッチしないとなりません。
そのため、オブジェクトのある位置を元に、「後ろの映像のテクスチャ」のUV座標を求める必要があります。

それを行っているのが以下の処理です。

o.uvgrab.xy = (float2(o.vertex.x, o.vertex.y*scale) + o.vertex.w) * 0.5;

w は同次座標系で利用するものですね。それを足して、さらに0.5倍しています。
最初これはなにをしているのだろうと思ったのですが、前述のように、オブジェクトの後ろ側のテクスチャの色を適切にフェッチするために正規化している、というわけです。

詳細は後述しますが、続くフラグメントシェーダでは以下のように色をフェッチしています。

half4 col = tex2Dproj( _GrabTexture, UNITY_PROJ_COORD(i.uvgrab));

こちらの射影テクスチャリングの記事を読むとイメージしやすいかもしれません。(射影空間からテクスチャ座標に変換)

フラグメントシェーダ

続くフラグメントシェーダ。法線(ノーマルマップ)からフェッチするUVのオフセットを計算し、キャプチャしたテクスチャから色をフェッチしています。

half4 frag (v2f i) : SV_Target
{
    // calculate perturbed coordinates
    half2 bump = UnpackNormal(tex2D( _BumpMap, i.uvbump )).rg; // we could optimize this by just reading the x & y without reconstructing the Z
    float2 offset = bump * _BumpAmt * _GrabTexture_TexelSize.xy;
    i.uvgrab.xy = offset * i.uvgrab.z + i.uvgrab.xy;
    
    half4 col = tex2Dproj( _GrabTexture, UNITY_PROJ_COORD(i.uvgrab));
    half4 tint = tex2D(_MainTex, i.uvmain);
    col *= tint;
    UNITY_APPLY_FOG(i.fogCoord, col);
    return col;
}

ノーマルマップからフェッチするUV座標にオフセットを適用しています。これが歪みを実現している箇所ですね。

そしてtex2Dprojを使っているのは、このシェーダが適用されたオブジェクトに、キャプチャした映像のテクスチャを「投影している」と考えるとイメージしやすいかと思います。
なので頂点シェーダでテクスチャ座標を計算していたんですね。

前に書いた以下の記事も参考になるかもしれないので貼っておきます。

ゴール駆動型エージェントの実装(実装編)

AI Unity C#

概要

ゴール駆動型エージェントの実装(概念編)」の続編です。
こちらでは、前回書いた概念を元に、実際にサンプルプロジェクトで実装した内容を解説していこうと思います。

※ 前回も書きましたが、あくまで書籍を読んで実際に自分が実装したものの解説です。そのため、理論など勘違い・間違いを含んでいる可能性が多分にあるため、あくまで実装の一例として御覧ください。

はじめに

さて、実際に実装について書いていく前に、イメージしやすくするための前提を書こうと思います。

まずはコードから読みたいよ! って人は下に書いてある実装解説から読んでください。

ゴール駆動型はStateパターンに似ている

実際に実装してみて感じたのは、いわゆるデザインパターンで言うところの「Stateパターン」にとても似ているな、ということ。

Stateパターンてなんぞ? という人はWikipediaなどを参考にしてもらいたいですが、ざっくり言うと、「State(状態)」をひとつのクラスとして定義し、そのインスタンスを差し替えることで状態による振る舞いの違いを表現する、というものです。

個人的なイメージはカートリッジを差し替えるとプログラムが変わる、みたいな感じですね。
(Aというカートリッジを挿すと、Aボタンはジャンプだけど、Bというカートリッジを挿すと同じAボタンでもしゃがむ、とか変化する)

「ゴール」が持つメソッドは3つ

どのあたりが似ていると感じたかと言うと、StateパターンではEnterExecuteExitという3つのメソッドを実装し、「とある状態に入ったとき」「とある状態にある間中」「とある状態から抜けるとき」を表します。
新しい状態クラスを作って、それぞれのメソッドをオーバーライドすることで状態が変化する際と「とある状態にある間中」に実行される内容を柔軟に切り替える/実行することができます。

そして、ゴール駆動型のGoalクラスも似たような3つのメソッドを持ちます。

  • Activate
  • Process
  • Terminate

の3つです。

基本的なイメージはそれぞれEnterExecuteExitと同様です。

ただ一点異なる点としては、Stateパターンの場合はEnterが呼ばれるのは状態が変化した際の1度きりなのに対し、ゴール駆動型ではActivateが(ゴールによっては)何度も呼ばれる可能性がある、という点です。

Activate

アクティブ化。ゴールが非アクティブ状態から実行された場合に起動される。前提処理やサブゴールのプランニングなど、ゴール実行開始時に必要な処理を行います。

Process

ゴールのメイン処理。
例えば「プレイヤーに近づく」というゴールの場合、ゲームループ中に毎フレームごとにProcessは実行され、その都度、プレイヤーの位置の補足と追従を実行したり、といったことを行います。

Terminate

ゴール「終了」時の後処理。あくまで終了時で、ゴールが成功したか失敗したかに関わらず、必ず実行されます。
Activate時に準備した内容を破棄したりなど、後処理が必要な場合にオーバーライドします。

ゴールは「失敗」することがある

Stateパターンの「状態」は、まさに状態そのものでそれに失敗も成功もありません。
状態が変化したら振る舞いがどうなるか、だけを定義します。

一方、ゴール駆動型のGoalクラスでは、内容は振る舞いを定義していますが、目的は振る舞いではなく、あくまでゴールに到達することです。
つまり、ゴールに向かうために取った行動が「振る舞い」として見えているに過ぎません。

そして大事な点として、ゴールは「失敗する」可能性がある、ということです。
具体例を上げてみると、「とある地点まで移動する」というゴールがあったとしましょう。
なにもない真っ平らな状態なら失敗することはありません。しかし、ほぼすべてのゲームにおいてそんな状況はあり得ません。

実際のゲームであれば道があったり、壁があったり障害物があったり。
なにかしら「とある地点」までの間に、到達不可能になる要因があるものです。

さて、実際にAIに「この地点まで行け」とゴールを設定して、一歩を踏み出したとしましょう。
最初は順調にその地点に向かって歩いています。

ところが途中で扉が閉まるなどして、どうやっても目的地にたどり着けないことが検知された場合どうでしょうか。
もはやそのゴールは達成不可能です。

これが「失敗することもある」ということの理由です。

図にすると下のようなイメージです。

ゴールを階層的に定義したイメージ図

f:id:edo_m18:20161126112722p:plain

(図での)最終ゴールである「直接攻撃」に失敗した場合は、その前の「プレイヤーに近づく」に戻る

f:id:edo_m18:20161126113413p:plain

失敗したら再プランニング

個人的な感想を書くと、ゴール駆動型の実装を読んだときに「なんてよくできているんだ」と思いました。

ゴールは失敗する前提になっていて、対象のゴールが失敗したら、より上位のゴール(親ゴール)に処理が戻されます。(思い出してください。ゴールはサブゴールの集合です)

そして親ゴールでもし再プランニングすることができるならここで再プランニングされます。
(例えば目的地まで移動するゴールが失敗した場合は、別の候補から別の目的地を決める、など)

もし親ゴールが再プランニングできない場合はさらに親ゴールに処理が戻ります。
そしてこのゴールの失敗の連鎖が続き、ルートゴールまで戻されると、ルートゴールは(前回書いたように)Brainクラスが担当しています。

Brainクラスは現在のAIの置かれている状況を把握し、プランナークラスからプランをもらってゴールを決定している中枢的存在です。
つまりここまで処理が戻る、ということはAIの状況になにかしらの変化が起きたことを意味しています。
結果として、Brainクラスは現在の状況からさらに最適なゴールを見つけ出すために動き出します。

これを繰り返すことで、まるでAIを、人が操作しているかのように柔軟な挙動を取らせることができる、というわけなのです。

前述の図を用いて表すと、例えばプレイヤーに直接攻撃が失敗した場合、遠距離攻撃を模索する、というような場合は以下のようになります。

f:id:edo_m18:20161126114112p:plain

ゴールは常に評価、更新される

先ほどからゴールの失敗やプランニング、という言葉を何度も使いましたが、これらはいつ、どこで行われるのでしょうか?
答えを先に書いてしまうと、「毎フレーム」です。

このあたりもStateパターンと似ていると思っている部分ですが、各ゴールは毎フレームごとに評価されます。
そのメソッドが前述したProcessメソッドです。

Processメソッドは毎フレーム(UnityのUpdateメソッドとほぼ同じタイミング)で呼び出され、常にゴールの状態を評価します。
そして「ゴールが失敗した」と判断すると、ゴールを「失敗状態」にし、親に処理を戻します。
そして次のループ処理の中で親ゴールが評価され、その後は前述のフローを辿って行く、という流れです。

ゴールは常に変化する

さて、感の言い方であればピンときたかもしれませんが、ゴールが常に評価される、ということは場合によってはゴールが突然変わる場合もありえます。
というか、なにかしらゲームをやっていることを想像してもらえば分かると思いますが、人の操作は常に変化していきます。
アクションゲームであれば、突然出てきた敵を迎撃するために立ち止まる、という選択を取るかもしれません。
RPGなどのコマンド式のゲームであっても、次のターンにはこうしようと思っていたが敵からの思わぬ攻撃のためにやむなく回復を優先した、なんてこともあるでしょう。

つまり、ゴールは常に変化し、状況に応じて柔軟に変化しなければならない、ということです。
むしろ柔軟に変化しないならそれはAIとは呼べませんね。

こうした、ゴールベースで行動を定義し、かつファジーな判断でリアルタイムにゴールを変更していくことで、AIはまるで生きているかのように振る舞うことができるのです。

f:id:edo_m18:20161126115847p:plain

また、ゴール自体が差し替えられずとも、現在実行しているゴールに差し込まれる形で別のゴールが設定されることもあります。

f:id:edo_m18:20161126120418p:plain

ゴールは状態を持つ

上で「失敗状態にし」と書きましたが、ゴールも「状態」を持ちます。
Stateパターンに似ていると書いたのに、ゴール自体にも「状態」があると書くと混乱するかもしれませんが、今目的としているゴールがどんな状態か、が確認できることはとても有用です。(Stateパターンの状態とは意味が異なるので注意してください)

前述のように、ゴールが達成困難になった場合は「失敗」と見なす必要があり、そうした「状態」が必要になってきます。

ゴールが持つ状態は4つ

ゴールクラスは inactive, active, completed, failed の4つの状態を持ちます。

inactive

非アクティブ状態。まだゴールが開始されていない状態です。
もし非アクティブ状態のゴールが「実行されるべき」状態となった場合は、前述の Activate メソッドを呼び出し、アクティブな状態に変化させます。

active

前述の inactive から、現在処理中を示すアクティブな状態へ変化したものが active です。
ゴール未達でかつ、まだ達成できる見込みがある場合は常に active 状態となります。

completed

完了状態。ゴールしたことを親ゴールに伝えます。

failed

ゴール失敗。前述の例で言えば「目的地に到達不可能になった状態」ですね。
失敗した場合は親ゴールにそれが伝わり、ゴールを再プランニングします。(前述の通りです)

実装解説

さて、だいぶ長々と前提について書きましたが、ここからは実際のコードを参考に、今回の実装内容を解説していきたいと思います。

ゴールクラスを実装する

まずはコードを見てもらったほうが早いと思うので、コードから。

namespace AI
{
    /// <summary>
    /// ゴールの状態
    /// </summary>
    public enum Status
    {
        Inactive,
        Active,
        Completed,
        Failed,
    }

    /// <summary>
    /// ゴールインターフェース
    /// </summary>
    public interface IGoal
    {
        bool IsInactive { get; }
        bool IsActive { get; }
        bool IsCompleted { get; }
        bool HasFailed { get; }

        void Activate();
        Status Process();
        void Terminate();
        void AddSubgoal(IGoal subgoal);
    }

    /// <summary>
    /// ゴールの基底クラス
    /// </summary>
    public class Goal<T> : IGoal where T : AIBase
    {
        protected T _owner;

        /// <summary>
        /// 非アクティブか
        /// </summary>
        public bool IsInactive { get { return _status == Status.Inactive; } }

        /// <summary>
        /// アクティブか
        /// </summary>
        public bool IsActive { get { return _status == Status.Active; } }

        /// <summary>
        /// 完了済か
        /// </summary>
        public bool IsCompleted { get { return _status == Status.Completed; } }

        /// <summary>
        /// ゴール失敗か
        /// </summary>
        public bool HasFailed { get { return _status == Status.Failed; } }

        /// <summary>
        /// 現在のステータス
        /// </summary>
        internal Status _status = Status.Inactive;

        public Goal(T owner)
        {
            _owner = owner;
        }


        /// <summary>
        /// 非アクティブならアクティブ状態に移行する
        /// </summary>
        internal void ActivateIfInactive()
        {
            if (IsInactive)
            {
                Activate();
            }
        }

        /// <summary>
        /// 失敗している場合はアクティブ化を試みる
        /// </summary>
        protected void ReactivateIfFailed()
        {
            if (HasFailed)
            {
                _status = Status.Inactive;
            }
        }

        /// <summary>
        /// アクティベイト処理
        /// </summary>
        public virtual void Activate()
        {
            Debug.Log("Start " + this);

            _status = Status.Active;
        }

        public virtual Status Process()
        {
            ActivateIfInactive();
            return _status;
        } 

        /// <summary>
        /// ゴールの後処理
        /// 成功/失敗に関わらず実行される
        /// </summary>
        public virtual void Terminate()
        {
            // do nothing.
        }

        public virtual void AddSubgoal(IGoal subgoal)
        {
            // do nothing.
        }
    }
}

ゴールの基底クラスです。
ベースとなる処理と、状態のプロパティのみ宣言しています。(つまりなにもしないゴール)
各ゴールについてはこれを継承して、実際の処理を記述していきます。

次に、サブゴールをまとめる親ゴールです。

CompositeGoal

サブゴールを束ねるゴールもまた、Goalクラスを継承したものになっています。
名称からも分かる通り、このゴールは「Compositeパターン」を利用して実装しています。

どんなものかをWikipediaから引用させてもらうと、

Composite パターン(コンポジット・パターン)とは、GoF(Gang of Four; 4人のギャングたち)によって定義された デザインパターンの1つである。「構造に関するパターン」に属する。Composite パターンを用いるとディレクトリとファイルなどのような、木構造を伴う再帰的なデータ構造を表すことができる。

ということになります。ファイルとフォルダ、というのは分かりやすい例でしょう。

Compositeパターンを簡単なクラス図にすると以下のようになります。

f:id:edo_m18:20161126111449p:plain

namespace AI
{
    /// <summary>
    /// サブゴールを持つゴール
    /// </summary>
    public class CompositeGoal<T> : Goal<T> where T : AIBase
    {
        /// <summary>
        /// サブゴールのリスト
        /// </summary>
        protected List<IGoal> _subgoals = new List<IGoal>();

        // コンストラクタ
        public CompositeGoal(T owner) : base(owner) { }

        #region Override

        public override Status Process()
        {
            ActivateIfInactive();
            return ProcessSubgoals();
        }

        /// <summary>
        /// サブゴールを追加
        /// </summary>
        /// <param name="subgoal"></param>
        public override void AddSubgoal(IGoal subgoal)
        {
            if (_subgoals.Contains(subgoal))
            {
                return;
            }

            _subgoals.Add(subgoal);
        }

        #endregion

        /// <summary>
        /// すべてのサブゴールを終了させ、クリアする
        /// </summary>
        protected void RemoveAllSubgoals()
        {
            foreach (var goal in _subgoals)
            {
                goal.Terminate();
            }

            _subgoals.Clear();
        }

        /// <summary>
        /// サブゴールを評価する
        /// </summary>
        /// <returns></returns>
        internal virtual Status ProcessSubgoals()
        {
            // サブゴールリストの中で完了 or 失敗のゴールをすべて終了させ、リストから削除する
            while (_subgoals.Count > 0 &&
                  (_subgoals[0].IsCompleted || _subgoals[0].HasFailed))
            {
                _subgoals[0].Terminate();
                _subgoals.RemoveAt(0);
            }

            // サブゴールがなくなったら完了。
            if (_subgoals.Count == 0)
            {
                _status = Status.Completed;
                return _status;
            }

            var firstGoal = _subgoals[0];

            // 残っているサブゴールの最前のゴールを評価する
            var subgoalStatus = firstGoal.Process();

            // 最前のゴールが完了していて、かつまだサブゴールが残っている場合は処理を継続する
            if ((subgoalStatus == Status.Completed) &&
                _subgoals.Count > 1)
            {
                _status = Status.Active;
                return _status;
            }

            return _status;
        } 
    }
}

サブゴールを束ねるゴールの定義は以上です。
基本的な処理はサブゴールを評価し、それが完了しているのか失敗しているのかのチェックと、サブゴールすべてが完了したら完了状態になる、というだけのシンプルなものです。

実際の挙動に関しては、目的に応じてこれらふたつのゴールのどちらかを継承してゴール派生クラスを作成していきます。(例えば、目的地に行くゴールであればGoalクラスを継承して、Processメソッド内で移動処理を記述する、という具合です)

全体を統括するBrainクラス

前回書いた通り、Brainクラスは全体のゴールを統括する役割を持っています。が、継承元はCompositeGoalです。

namespace AI
{
    /// <summary>
    /// ゴールを統括するルートゴール
    /// </summary>
    public class Brain<T> : CompositeGoal<T> where T : AIBase
    {
        // プランナー
        private IPlanner _planner;

        // 現在選択されているプラン
        private PlanBase _currentPlan;

        // 短期記憶しているオブジェクトを保持
        private List<Memory> _shortMemories = new List<Memory>();

        // 長期記憶しているオブジェクトを保持
        private List<Memory> _longMemories = new List<Memory>();

        /// <summary>
        /// 記憶しているすべてのオブジェクトを返す
        /// </summary>
        private List<Memory> AllMemories
        {
            get
            {
                List<Memory> allMemories = new List<Memory>();
                allMemories.AddRange(_shortMemories);
                allMemories.AddRange(_longMemories);
                return allMemories;
            }
        }

        #region Constructor

        // コンストラクタ
        public Brain(T owner) : base(owner)
        {
            // NOTE:
            // 今回は簡単のためプランナーを直接生成しているが、
            // DI的に設定したほうが汎用性は高い
            _planner = new CharaPlanner(owner);
        }

        #endregion


        #region Public members

        /// <summary>
        /// プランを記憶に保持
        /// </summary>
        /// <param name="planObject"></param>
        public void Memorize(PlanObject planObject)
        {
            // 重複しているプランは追加しない
            if (HasMemory(planObject))
            {
                return;
            }

            _shortMemories.Add(MakeMemory(planObject));
        }

        /// <summary>
        /// メモリコントロール
        /// すでに達成したプランなど、記憶から消すべきオブジェクトをリストから削除する
        /// </summary>
        public void MemoryControl()
        {
            var targets = from m in _shortMemories
                          where m.Target != null
                          select m;

            var newList = targets.ToList();
            _shortMemories = newList;
        }

        #endregion


        #region Private members

        /// <summary>
        /// プランリストから最適なプランを評価、取得する
        /// </summary>
        /// <returns></returns>
        PlanBase EvaluatePlans()
        {
            List<PlanBase> plans = EnumeratePlans();
            return _planner.Evaluate(plans);
        }

        /// <summary>
        /// 短期・長期記憶双方に保持しているプランを列挙する
        /// </summary>
        /// <returns></returns>
        List<PlanBase> EnumeratePlans()
        {
            var plans = new List<PlanBase>();
            foreach (var m in AllMemories)
            {
                plans.Add(m.Plan);
            }
            return plans;
        }

        /// <summary>
        /// プランに応じてゴールを選択する
        /// </summary>
        /// <param name="plan"></param>
        /// <returns></returns>
        IGoal GetGoalByPlan(PlanBase plan)
        {
            switch (plan.GoalType)
            {
                // あたりを探し回る
                case GoalType.Wander:
                {
                    return new GoalWander<T>(_owner);
                }

                // エネルギー/パワーを得る
                case GoalType.GetEnergy:
                case GoalType.GetPower:
                {
                    var memory = FindMemory(plan);
                    return new GoalGetItem<T>(_owner, memory.Target);
                }

                // 敵を攻撃
                case GoalType.Attack:
                {
                    var memory = FindMemory(plan);
                    return new GoalAttackTarget<T>(_owner, memory.Target);
                }
            }
            
            return new Goal<T>(_owner);
        }

        /// <summary>
        /// 選択中のプランからプランを変更する
        /// </summary>
        /// <param name="newPlan"></param>
        void ChangePlan(PlanBase newPlan)
        {
            Debug.Log("Change plan to " + newPlan);

            _currentPlan = newPlan;
            RemoveAllSubgoals();

            var goal = GetGoalByPlan(newPlan);
            AddSubgoal(goal);
        }

        /// <summary>
        /// プランオブジェクトからメモリオブジェクトを生成する
        /// </summary>
        Memory MakeMemory(PlanObject planObject)
        {
            var memory = new Memory(planObject);
            return memory;
        }

        /// <summary>
        /// 対象プランから記憶オブジェクトを検索
        /// </summary>
        Memory FindMemory(PlanBase plan)
        {
            return AllMemories.Find(m => m.Plan == plan);
        }

        /// <summary>
        /// 記憶にあるプランか
        /// </summary>
        bool HasMemory(PlanObject planObject)
        {
            var memory = AllMemories.Find(m => m.Plan == planObject.Plan);
            return memory != null;
        }

        /// <summary>
        /// 記憶にあるメモリか
        /// </summary>
        bool HasMemory(Memory memory)
        {
            var storeMem = AllMemories.Find(m => m == memory);
            return storeMem != null;
        }

        #endregion


        #region Override Goal class

        public override void Activate()
        {
            base.Activate();

            RemoveAllSubgoals();

            // なにもないときにあたりを歩き回るプランを設定しておく
            var memory = new Memory();
            memory.Plan = new PlanWander();

            _longMemories.Add(memory);
        }

        public override Status Process()
        {
            ActivateIfInactive();

            PlanBase selectedPlan = EvaluatePlans();
            bool needsChangePlan = (selectedPlan != null) && (_currentPlan != selectedPlan);
            if (needsChangePlan)
            {
                ChangePlan(selectedPlan);
            }

            return ProcessSubgoals();
        }

        public override void Terminate()
        {
            base.Terminate();
        }

        #endregion
    }
}

Brainクラスはその名の通り、AIの挙動を決定する大事な役割を担います。
保持しているAIBase(Owner)の情報と、現在記憶している状態を元に、行動すべき内容を決定します。

短期記憶と長期記憶

プランニング対象となるオブジェクトに関しては記憶に蓄えられます。
また、人間と同様に「長期記憶」と「短期記憶」に分けて保持しておくことで、一定時間経過したら忘れてしまう、という挙動や、忘れることなく必ず実行させる、ということが可能になります。

短期記憶させるものとしては、例えば回復アイテムの位置を覚えさせておいたり、武器の場所を覚えさせておいたり、といったことが考えられます。
当然AIは自分の判断において行動するため、「見つけた」タイミングでそれらのアイテムを取りに行くとは限りません。

そのため、いったん記憶に留めておいてから、必要になったタイミングでそれを取り出し、その場所に向かうことで自然な動きを実現している、というわけです。

長期記憶に適しているものとしては、例えば陣取りゲームなどの場合は最重要ゴールは「陣地を取る」ということです。
こうしたことを「忘れて」しまっては困るので、直近でやるべきゴールがない場合に自動的に選択され、かつ消去されないように長期記憶に留めておくなどするといいでしょう。

記憶にとどめる処理自体は、AIがなにか記憶に留めておくべきものを「見つけた」ときに記憶されます。
見つける方法は様々です。今回のサンプルでは単に、isTriggerなコライダを用いて、記憶可能オブジェクトが検知エリア内に入った場合にそれを保持するようにしています。

今回はコライダを設定していますが、例えば視覚を表現するようにキャラクターの目の前のオブジェクトだけを対象としたり、あるいは「音」に反応するようにしてもいいと思います。
要は「外界を認識するセンサー」をエージェントに実装し、それらが「知覚」したときに記憶に留めることをすれば、より自然な形で記憶に留めさせることができます。

f:id:edo_m18:20161126122632p:plain
サンプルではSphereColliderでセンサーを表現

プランを選択する「プランナー」

そして実際に「どのプランを選択するか」については「プランナー」クラスを設けています。
プランナークラスに判断を任せることで、あとから性格を変えたりといったことが容易になるようにしています。

ということで、プランナークラスを見てみましょう。

PlannerBase

PlannerBaseクラスはIPlannerインターフェースを実装し、プランナーのベースとなる処理を実装しています。
プランの実際の評価については派生クラスに任せています。

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

namespace AI
{
    /// <summary>
    /// プランナーインターフェース
    /// </summary>
    interface IPlanner
    {
        float EvaluatePlan(PlanBase plan);
        PlanBase Evaluate(List<PlanBase> plans);
    }

    /// <summary>
    /// プランナーのベースクラス
    ///
    /// プランリストから適切なプランを選択する
    /// </summary>
    public class PlannerBase<T> : IPlanner where T : AIBase
    {
        protected T _owner;

        #region Constructor

        // コンストラクタ
        public PlannerBase(T owner)
        {
            _owner = owner;
        }

        #endregion


        /// <summary>
        /// プランリストを評価して、報酬見込みが一番高いものを返す
        /// </summary>
        /// <param name="plans">評価対象のプランリスト</param>
        /// <returns>選択されたプラン</returns>
        public virtual PlanBase Evaluate(List<PlanBase> plans)
        {
            float maxValue = 0f;
            PlanBase selectedPlan = null;
            foreach (var plan in plans)
            {
                float value = EvaluatePlan(plan);
                if (maxValue <= value)
                {
                    maxValue = value;
                    selectedPlan = plan;
                }
            }

            return selectedPlan;
        }

        /// <summary>
        /// プランを評価する
        /// </summary>
        /// <param name="plan">評価対象のプラン</param>
        /// <returns>オーナーの現在の状態を加味したプランに応じた報酬見込み値</returns>
        public virtual float EvaluatePlan(PlanBase plan)
        {
            return 0f;
        }
    }
}

大事なポイントはEvalute(List<PlanBase> plans)メソッドです。
プランナーはプランリストを受け取り、その中からオーナーのパラメータを元に「最適」となるプランを選択します。
最適なプランの選択は派生クラスの評価のロジックに任されています。

CharaPlanner
using UnityEngine;
using System.Collections;

namespace AI
{
    public class CharaPlanner : PlannerBase<AIBase>
    {
        #region Constructor

        // コンストラクタ
        public CharaPlanner(AIBase owner) : base(owner) { }

        #endregion


        /// <summary>
        /// プランを評価する
        /// 方針は、できるだけエネルギーを蓄えつつ、パワーを拡充していく性格。
        /// つまりエネルギーの補充に充填を置く挙動にする。
        /// </summary>
        public override float EvaluatePlan(PlanBase plan)
        {
            float value = 0f;

            // 攻撃プランの場合は、オーナーの状態を見て攻撃に転じるかを判断する
            if (plan.GoalType == GoalType.Attack)
            {
                // パワーがない場合は攻撃できない
                if (_owner.AttackPower == 0.0f)
                {
                    return 0f;
                }

                value += Mathf.Pow(_owner.Energy, 2f);
                value += Mathf.Pow(_owner.AttackPower, 1.5f);
                return value;
            }

            foreach (var reward in plan.RewardProspects)
            {
                switch (reward.RewardType)
                {
                    case RewardType.Enegy:
                        value += Mathf.Pow(1f - _owner.Energy, 2f) * reward.Value;
                        break;

                    case RewardType.Power:
                        value += Mathf.Pow(1f - _owner.AttackPower, 3f) * reward.Value;
                        break;
                }
            }

            return value;
        } 
    }
}

今回のサンプルではキャラクターはひとりしかいないため、CharaPlannerのみの実装です。
これを複数種類実装して、それぞれのキャラクターごとに変えることで、キャラごとの性格を変える、といったことも可能です。

CharaPlannerはシンプルにEvaluatePlan(PlanBase plan)メソッドをオーバーライドして、プランごとの評価部分を実装しています。
プラン選択の要はこのメソッドです。

見てもらうと分かる通り、各プランに設定された「報酬」を元に、2次関数の形で報酬を評価しています。
当然これを3次関数にしたり、あるいはまったく別のロジックを入れることによって、柔軟に、選択するプランに変化を与えることが可能になります。
(例えば、上のサンプルで言えばエネルギーの評価値を上げておくことで、ひたすらエネルギー回収に向かうAIにする、といった具合です)

前回の記事で「ファジーに判断する」と書いたのがまさにこの部分です。
いったんすべてのプランを評価し、その中で「一番評価の高かった(つまりAIにとって最適の)プラン」を選択することで、AIがまるで人が操作しているかのような「状況を判断して行動している」様子を表現している、というわけです。

評価は報酬から計算する

さて、最後は報酬とプラン選択のロジックについて少し深掘りして終わりにしたいと思います。
(さすがにすべてのクラス、実装を解説するには長くなりすぎるので)

前述の通り、プランは報酬を計算して最終的に選択されます。報酬自体はただの数値的なパラメータです。
(例えばこれを手に入れたらライフがこれだけ回復する、とか、これを手に入れたらエネルギーがこれだけ充填される、など)

こうしたパラメータを元に、オーナーの状態(例えばライフが少なくてピンチ! など)に応じて、適切なプランが選択されるようにロジックを組めば、AIがさも人が操作しているかのような錯覚を生み出すことができます。

ではその計算ロジックはどうしたらいいでしょうか。

正直なところ、答えはありません。というより、キャラクターに取らせたい行動に応じて最適解が変わってきます。
このあたりの調整はゲームの面白さを左右する部分にもなるので大変、でも楽しい部分ではないでしょうか。

とはいえこれでは解説にならないので、今回実装したものを解説すると、2次関数として実装しています。
具体的には以下の部分です。

value += Mathf.Pow(1f - _owner.Energy, 2f) * reward.Value;

1からオーナーのパラメータを引いたものを二乗したものに、報酬の値を掛け算して求めています。
これはつまり、オーナーのエネルギーが最大(1が最大)の場合、報酬見込み値は0になります。(1 - 1 = 0です)

逆に、オーナーのエネルギーが減ってくると2次関数的に見込みが増加します。
実際に値を入れてみると分かりやすいと思います。

pow(1 - 0.9, 2) = 0.1 * 0.1 = 0.01
pow(1 - 0.5, 2) = 0.5 * 0.5 = 0.25
pow(1 - 0.1, 2) = 0.9 * 0.9 = 0.81

という具合です。

※ シェーダもそうですが、0~1で表現するとこうも値が扱いやすくなるのはほんとすごいなと思ってます。

小数点の2次関数とすることで、以下のように充足度が減るにつれて、その報酬見込みが顕著に上昇していくようになります。
またこれらの式を工夫することで、最初は興味がない事柄に対しても、途中から急激に興味を示す、というようなことが可能になります。

f:id:edo_m18:20161126170858p:plain

まとめ

さて、いかがだったでしょうか。
AIが作れるようになると、ゲームの幅がとても広がります。

そしてゲームに限らず、ゲームAIの理論を組み合わせてサービスの向上を図ることもできるのではないかなと思っています。
今回は「ゴール駆動型」のAIの紹介・解説でしたが、参考にした書籍「実例で学ぶ ゲームAIプログラミング」では様々なAIについて解説されています。

例えば、

などなど、組み合わせることでより多様なAIを作れる内容が盛り沢山です。

経路探索などはUnityではNavMeshなどで手軽に実装できますが、理論を知っておくと色々と応用が効くのでおすすめです。
A*ではありませんが、過去にダイクストラのアルゴリズムについても記事を書いたので、興味がある人は読んてみてください。

qiita.com