20210120のUnityに関する記事は12件です。

【Unity】角度を扱うときはfloatじゃなくて専用のAngle構造体を用意すると捗る

角度の扱いづらさ

角度を普通にfloatやdoubleで扱おうとすると、次のような問題が発生して扱いづらいです。

  • 度数法(degree)なのか弧度法(radian)なのかわからない
  • 360°以上になると角度値は違うけど見た目は同じになってしまいややこしくなる
    • 360°と0°では角度値は違えど見た目は変わらない
    • 180°と-180°、45°と405°なども同様。見た目は同じだけど内部値が全く違うので意図しない動きになったりする

これらの問題を専用の変換メソッド等を用意して解決しても良いのですが、グローバルなメソッドにすれば角度以外のfloat値に対して適切ではないメソッドが呼べてしまうし、必要な部分だけにスコープを限定したメソッドとして定義すれば再利用性が失われてしまいます

それに、「角度」のように何らかの意味のある数値として扱う場合は、プリミティブ型をそのまま代用するのではなく、専用のオブジェクトとして定義したほうが、専用の操作をオブジェクト内に隠蔽することができるため、可読性や保守性に富みます。

というわけで、角度を表すAngle構造体を作りました。

角度を表すAngle構造体

作成したAngle構造体の機能を紹介します。
今すぐコードが見たい方はこちら

インスタンスの生成を行うファクトリ

度数法と弧度法を混同させないため、コンストラクタは隠蔽しています。
その代わりに各種ファクトリメソッドを用意しています。

Angle.FromDegreeファクトリメソッド

度数法の値からAngle構造体のインスタンスを取得します。

Angle angle = Angle.FromDegree(60);

周回数を指定することもできます。

Angle angle = Angle.FromDegree(1, 60); //360°+60°

Angle.FromRadianファクトリメソッド

弧度法の値からAngle構造体のインスタンスを取得します。

Angle angle = Angle.FromRadian(UnityEngine.Mathf.PI);

こちらも同様に、周回数を指定することもできます。

Angle angle = Angle.FromRadian(-2, UnityEngine.Mathf.PI); //-4π+π

Angle.Zeroファクトリプロパティ

角度が0のAngle構造体のインスタンスを取得します。

Angle angle = Angle.Zero; //0°

Angle.Roundファクトリプロパティ

角度が360°のAngle構造体のインスタンスを取得します。

Angle angle = Angle.Round; //360°

各種変換を行うメソッド

各種変換メソッドを提供します。
イミュータブルな設計とするため、変換メソッドを実行しても元のインスタンスは変更されず、新しいインスタンスを返すようになっています。

Normalizeメソッド

角度を-180°<θ<=180°の範囲で正規化します。

Normalize.png

例えば、225°の角度は-180°<θ<180°の間には入っていないため、-135°に正規化されます。

Angle angle = Angle.FromDegree(225).Normalize(); //-135°

Normalize1.png

同様に、-450°は-90°に正規化されます。

Angle angle = Angle.FromDegree(-450).Normalize(); //-90°

Normalize2.png

PositiveNormalizeメソッド

角度を0°<=θ<360°の範囲で正規化します。

PositiveNormalize.png

例えば、-135°を0°<=θ<360°の範囲に正規化すると、225°となります。

Angle angle = Angle.FromDegree(-135).PositiveNormalize(); //225°

PositiveNormalize1.png

同様に、-450°は270°に正規化されます。

Angle angle = Angle.FromDegree(-450).PositiveNormalize(); //270°

PositiveNormalize2.png

Reverseメソッド

Reverseメソッドは、次のように見た目上の角度を変更せずに、方向のみを反転させます。

Angle angle = Angle.FromDegree(90).Reverse(); //-270°

Reverse1.png

Angle angle = Angle.FromDegree(-450).Reverse(); //630°

Reverse2.png

SignReverseメソッド

SignReverseメソッドは、角度の符号を単純に反転させます。

Angle angle = Angle.FromDegree(90).SignReverse(); //-90°

SignReverse.png

Absoluteメソッド

Absoluteメソッドは、SignReverseメソッドの正の方向への片道切符バージョンです。

Angle angle = Angle.FromDegree(90).Absolute(); //90°
Angle angle = Angle.FromDegree(-90).Absolute(); //90°

情報の取得を行うプロパティ

Angle構造体から角度情報を取得することができます。

TotalDegreeプロパティ

角度値を度数法で取得します。

float deg1 = Angle.FromRadian(UnityEngine.Mathf.PI).TotalDegree; //180f
float deg2 = Angle.FromDegree(-1, -90).TotalDegree; //-450f

TotalRadianプロパティ

角度値を弧度法で取得します。

float rad1 = Angle.FromRadian(UnityEngine.Mathf.PI).TotalRadian; //π
float rad2 = Angle.FromDegree(-1, -90).TotalRadian; //-5π/4

NormalizedDegreeプロパティ

Normalizeした角度値を度数法で取得します。

float deg1 = Angle.FromRadian(UnityEngine.Mathf.PI).NormalizedDegree; //180f
float deg2 = Angle.FromDegree(-1, -90).NormalizedDegree; //-90f

NormalizedRadianプロパティ

NormalizedDegreeプロパティの弧度法バージョン。

PositiveNormalizedDegreeプロパティ

NormalizedDegreeプロパティのPositiveNormalizeしたバージョン。

PositiveNoramlizedRadianプロパティ

PositiveNormalizedDegreeプロパティの弧度法バージョン。

Lapプロパティ

角度が何周しているかを取得します。

int lap1 = Angle.FromDegree(180).Lap; //0
int lap2 = Angle.FromDegree(360).Lap; //1
int lap3 = Angle.FromDegree(-730).Lap; //-2

IsCircledプロパティ

角度が1周以上回っているかどうかを取得します。

bool circled1 = Angle.FromDegree(180).IsCircled; //false
bool circled2 = Angle.FromDegree(360).IsCircled; //true
bool circled3 = Angle.FromDegree(-730).IsCircled; //true

IsTrueCircleプロパティ

角度が360°の倍数かどうかを取得します。

bool trueCircle1 = Angle.FromDegree(180).IsTrueCircled; //false
bool trueCircle2 = Angle.FromDegree(360).IsTrueCircled; //true
bool trueCircle3 = Angle.FromDegree(-730).IsTrueCircled; //false

IsPositiveプロパティ

正の方向への角度かどうか取得します。

bool circled1 = Angle.FromDegree(180).IsPositive; //true
bool circled2 = Angle.FromDegree(360).IsPositive; //true
bool circled3 = Angle.FromDegree(-730).IsPositive; //false

演算子

各種演算子をオーバーロードしており、プリミティブ型と同じように各種演算をすることができます。

+-演算子

角度を加算/減算します。

var plusAngle = Angle.FromDegree(120) + Angle.Round; //480°
var minusAngle = Angle.FromDegree(45) - Angle.Round; //-315°

*/演算子

角度を実数で乗算/除算します。

var multiAngle = Angle.FromDegree(120) * -3; //-360°
var divideAngle = Angle.FromDegree(120) / 4; //30°

==,!=,<,<=,>,>=演算子

角度の大きさを比較します。

var b1 = Angle.FromDegree(90) == Angle.FromDegree(90); //true 
var b2 = Angle.FromDegree(90) != Angle.FromDegree(450); //true 
var b3 = Angle.FromDegree(90) < Angle.FromDegree(45); //false
var b4 = Angle.FromDegree(90) <= Angle.FromDegree(90); //true 
var b5 = Angle.FromDegree(90) > Angle.FromDegree(45); //true 
var b6 = Angle.FromDegree(90) >= Angle.FromDegree(45); //true 

インターフェイス実装

次の2つのインターフェイスを実装しています。

IEquatable<Angle>インターフェイス

等価性比較のためにIEquatable<Angle>インターフェイスを実装しています。

==演算子があるのにわざわざIEquatable<Angle>インターフェイスを実装するメリットはこの記事が参考になりますが、要約すると次のようになります。

  • IEquatable<Angle>がないとobject版のEquals(object obj)が呼ばれることになる。値型をobjectにキャストするとボックス化が発生するのでオーバーヘッドがかかってしまう
  • 構造体のobject版のEquals(object obj)メソッドの既定の動作は、すべてのフィールドの等価性を比較すること。これが==演算子の比較内容と異なると、==演算子の結果とEqualsメソッドの結果に相違が生じ、混乱を招いてしまう
    • しかも、等価性比較はリフレクションを用いて行われる模様なので、速度が圧倒的に遅い

このような理由から、構造体の場合は基本的にIEquatable<T>インターフェイスを実装したほうが良いようです。

IComparable<Angle>インターフェイス

LINQのOrderByメソッドやMax,Minメソッドを利用できるようにするためにIComparable<Angle>インターフェイスを実装しています。

オーバーライド

object型に定義されている次のメソッドをオーバーライドしています。

ToStringメソッド

周回数と残りの角度を返します。

string str = Angle.FromDegree(2, 45).ToString(); //2x + 45°

ToStringをオーバーライドしていると、VisualStudioのデータヒントにも同様のフォーマットで表示されるので便利です。
image.png

Equals(object o)メソッド

IEquatable<Angle>インターフェイスのEqualsメソッドも実装したのですが、objectクラスのEqualsメソッドもオーバーライドしています。
理由としては、前述の通りEquals(object o)メソッドの既定の動作が全フィールドの等価性比較であること、しかもそれがリフレクションによる比較であるためです。
Angle構造体は内部で持つ角度値を単純に比較するだけで良いので、リフレクションを使う既定の動作をオーバーライドして封印します。

ちなみに、VisualStudioでは==演算子をオーバーロードするとEqualsメソッドとGetHashCodeメソッドもオーバーライドしなさいと警告が出ます。
==演算子をオーバーロードしている→自前で等価性比較処理が書けている→既定のEqualsを使う必要がない→だったらオーバーライドしろ、ということですね。
image.png

GetHashCodeメソッド

GetHashCodeメソッドはDictionaryのキーとして使うときに使われるようです。

A hash code is a numeric value that is used to insert and identify an object in a hash-based collection such as the Dictionary class, the Hashtable class, or a type derived from the DictionaryBase class. The GetHashCode method provides this hash code for algorithms that need quick checks of object equality.

https://docs.microsoft.com/en-us/dotnet/api/system.object.gethashcode?view=net-5.0

これをオーバーライドしないとDictionaryのキーとして使ったときに正しく動作しない可能性がある模様。

VisualStudioがオーバーライドをおすすめしてくれたのでオーバーライドします。
しかも有能VisualStudioが中身も自動で実装してくれます。

ぶっちゃけあまり詳しくない。

ソースコード本体

上記機能を備えたAngle構造体のソースコードです。
そのままコピペで使えます。

[追記]
編集リクエスト頂きライセンスを明記しました。
改変等自由ですので是非ご利用ください。

/*
Angle.cs

Copyright (c) 2021 yutorisan

This software is released under the MIT License.
http://opensource.org/licenses/mit-license.php
*/

using System;
using UnityEngine;

namespace UnityUtility
{
    /// <summary>
    /// 角度
    /// </summary>
    public readonly struct Angle : IEquatable<Angle>, IComparable<Angle>
    {
        /// <summary>
        /// 正規化していない角度の累積値
        /// </summary>
        private readonly float m_totalDegree;

        /// <summary>
        /// 角度を度数法で指定して、新規インスタンスを作成します。
        /// </summary>
        /// <param name="angle">度数法の角度</param>
        /// <exception cref="NotFiniteNumberException"/>
        private Angle(float angle) => m_totalDegree = ArithmeticCheck(() => angle);
        /// <summary>
        /// 周回数と角度を指定して、新規インスタンスを作成します。
        /// </summary>
        /// <param name="lap">周回数</param>
        /// <param name="angle">度数法の角度</param>
        /// <exception cref="NotFiniteNumberException"/>
        /// <exception cref="OverflowException"/>
        private Angle(int lap, float angle) => m_totalDegree = ArithmeticCheck(() => checked(360 * lap + angle));

        /// <summary>
        /// 度数法の値を使用して新規インスタンスを取得します。
        /// </summary>
        /// <param name="degree">度数法の角度(°)</param>
        /// <returns></returns>
        /// <exception cref="NotFiniteNumberException"/>
        public static Angle FromDegree(float degree) => new Angle(degree);
        /// <summary>
        /// 周回数と角度を指定して、新規インスタンスを取得します。
        /// </summary>
        /// <param name="lap">周回数</param>
        /// <param name="degree">度数法の角度(°)</param>
        /// <returns></returns>
        /// <exception cref="NotFiniteNumberException"/>
        public static Angle FromDegree(int lap, float degree) => new Angle(lap, degree);
        /// <summary>
        /// 弧度法の値を使用して新規インスタンスを取得します。
        /// </summary>
        /// <param name="radian">弧度法の角度(rad)</param>
        /// <returns></returns>
        /// <exception cref="NotFiniteNumberException"/>
        public static Angle FromRadian(float radian) => new Angle(RadToDeg(radian));
        /// <summary>
        /// 周回数と角度を指定して、新規インスタンスを取得します。
        /// </summary>
        /// <param name="lap">周回数</param>
        /// <param name="radian">弧度法の角度(rad)</param>
        /// <returns></returns>
        /// <exception cref="NotFiniteNumberException"/>
        public static Angle FromRadian(int lap, float radian) => new Angle(lap, RadToDeg(radian));
        /// <summary>
        /// 角度0°の新規インスタンスを取得します。
        /// </summary>
        public static Angle Zero => new Angle(0);
        /// <summary>
        /// 角度360°の新規インスタンスを取得します。
        /// </summary>
        public static Angle Round => new Angle(360);

        public bool Equals(Angle other) => m_totalDegree == other.m_totalDegree;

        public override int GetHashCode() => -1748791360 + m_totalDegree.GetHashCode();

        public override string ToString() => $"{Lap}x + {m_totalDegree - Lap * 360}°";

        public override bool Equals(object obj)
        {
            if (obj is Angle angle) return Equals(angle);
            else return false;
        }

        public int CompareTo(Angle other) => m_totalDegree.CompareTo(other.m_totalDegree);

        /// <summary>
        /// 正規化された角度(-180° &lt; degree &lt;= 180°)を取得します。
        /// </summary>
        /// <returns></returns>
        public Angle Normalize() => new Angle(NormalizedDegree);

        /// <summary>
        /// 正の値で正規化された角度(0° &lt;= degree &lt; 360°)を取得します。
        /// </summary>
        /// <returns></returns>
        public Angle PositiveNormalize() => new Angle(PositiveNormalizedDegree);

        /// <summary>
        /// 方向を反転させた角度を取得します。
        /// 例:90°→-270°, -450°→630°
        /// </summary>
        /// <returns></returns>
        public Angle Reverse()
        {
            //ゼロならゼロ
            if (this == Zero) return Zero;
            //真円の場合は真逆にする
            if (IsTrueCircle) return new Angle(-Lap, 0);
            if (IsCircled)
            { //1周以上している
                if (IsPositive)
                { //360~
                    return new Angle(-Lap, NormalizedDegree - 360);
                }
                else
                { //~-360
                    return new Angle(-Lap, NormalizedDegree + 360);
                }
            }
            else
            { //1周していない
                if (IsPositive)
                { //0~360
                    return new Angle(m_totalDegree - 360);
                }
                else
                { //-360~0
                    return new Angle(m_totalDegree + 360);
                }
            }
        }
        /// <summary>
        /// 符号を反転させた角度を取得します。
        /// </summary>
        /// <returns></returns>
        public Angle SignReverse() => new Angle(-m_totalDegree);
        /// <summary>
        /// 角度の絶対値を取得します。
        /// </summary>
        /// <returns></returns>
        public Angle Absolute() => IsPositive ? this : SignReverse();

        /// <summary>
        /// 正規化していない角度値を取得します。
        /// </summary>
        public float TotalDegree => m_totalDegree;
        /// <summary>
        /// 正規化していない角度値をラジアンで取得します。
        /// </summary>
        public float TotalRadian => DegToRad(TotalDegree);
        /// <summary>
        /// 正規化された角度値(-180 &lt; angle &lt;= 180)を取得します。
        /// </summary>
        public float NormalizedDegree
        {
            get
            {
                float lapExcludedDegree = m_totalDegree - (Lap * 360);
                if (lapExcludedDegree > 180) return lapExcludedDegree - 360;
                if (lapExcludedDegree <= -180) return lapExcludedDegree + 360;
                return lapExcludedDegree;
            }
        }
        /// <summary>
        /// 正規化された角度値をラジアン(-π &lt; rad &lt; π)で取得します。
        /// </summary>
        public float NormalizedRadian => DegToRad(NormalizedDegree);
        /// <summary>
        /// 正規化された角度値(0 &lt;= angle &lt; 360)を取得します。
        /// </summary>
        public float PositiveNormalizedDegree
        {
            get
            {
                var normalized = NormalizedDegree;
                return normalized >= 0 ? normalized : normalized + 360;
            }
        }

        /// <summary>
        /// 正規化された角度値をラジアン(0 &lt;= rad &lt; 2π)で取得します。
        /// </summary>
        public float PositiveNormalizedRadian => DegToRad(PositiveNormalizedDegree);
        /// <summary>
        /// 角度が何周しているかを取得します。
        /// 例:370°→1周, -1085°→-3周
        /// </summary>
        public int Lap => ((int)m_totalDegree) / 360;
        /// <summary>
        /// 1周以上しているかどうか(360°以上、もしくは-360°以下かどうか)を取得します。
        /// </summary>
        public bool IsCircled => Lap != 0;
        /// <summary>
        /// 360の倍数の角度であるかどうかを取得します。
        /// </summary>
        public bool IsTrueCircle => IsCircled && m_totalDegree % 360 == 0;
        /// <summary>
        /// 正の角度かどうかを取得します。
        /// </summary>
        public bool IsPositive => m_totalDegree >= 0;

        /// <exception cref="NotFiniteNumberException"/>
        public static Angle operator +(Angle left, Angle right) => new Angle(ArithmeticCheck(() => left.m_totalDegree + right.m_totalDegree));
        /// <exception cref="NotFiniteNumberException"/>
        public static Angle operator -(Angle left, Angle right) => new Angle(ArithmeticCheck(() => left.m_totalDegree - right.m_totalDegree));
        /// <exception cref="NotFiniteNumberException"/>
        public static Angle operator *(Angle left, float right) => new Angle(ArithmeticCheck(() => left.m_totalDegree * right));
        /// <exception cref="NotFiniteNumberException"/>
        public static Angle operator /(Angle left, float right) => new Angle(ArithmeticCheck(() => left.m_totalDegree / right));
        public static bool operator ==(Angle left, Angle right) => left.m_totalDegree == right.m_totalDegree;
        public static bool operator !=(Angle left, Angle right) => left.m_totalDegree != right.m_totalDegree;
        public static bool operator >(Angle left, Angle right) => left.m_totalDegree > right.m_totalDegree;
        public static bool operator <(Angle left, Angle right) => left.m_totalDegree < right.m_totalDegree;
        public static bool operator >=(Angle left, Angle right) => left.m_totalDegree >= right.m_totalDegree;
        public static bool operator <=(Angle left, Angle right) => left.m_totalDegree <= right.m_totalDegree;

        /// <summary>
        /// 演算結果が数値であることを確かめる
        /// </summary>
        /// <param name="func"></param>
        /// <returns></returns>
        private static float ArithmeticCheck(Func<float> func)
        {
            var ans = func();
            if (float.IsInfinity(ans)) throw new NotFiniteNumberException("演算の結果、角度が正の無限大または負の無限大になりました");
            if (float.IsNaN(ans)) throw new NotFiniteNumberException("演算の結果、角度がNaNになりました");
            return ans;
        }
        private static float RadToDeg(float rad) => rad * 180 / Mathf.PI;
        private static float DegToRad(float deg) => deg * (Mathf.PI / 180);
    }
}


単体テスト

Angle構造体はこれから長く使い続けられそうなので、きちんと単体テストを行いました。
次の記事を参考にしながら、xUnitChainning Assertionを使ってテストコードを記述しました。
xUnit.net でユニットテストを始める

以下がテストコードです。
IComparable<Angle>インターフェイスを実装したことにより、OrderByによる並べ替えも成功しています。

例外処理も可能な限りチェックしたかったのですが、Angle構造体は内部に単精度浮動小数点型(float)を使っているので、どうしても最大値・最小値の扱いが難しいです(例えばfloat.MaxValue+1000などとしても、floatの有効数字は7桁なので1000が丸め込まれてしまう)。
そのため明確にNaNもしくはInfinityまたはNegativeInfinityになった場合のみ例外の発生をチェックしました。
実際、3.40282347×10^38°なんて角度を使うことはほぼありえないため目をつむります。floatの仕様に依存すると言えば言い訳になるかも。

using System;
using UnityUtility;
using UnityEngine;
using Xunit;
using System.Linq;
using System.Collections.Generic;

namespace AngleStructUnitTest
{
    public class UnitTest1
    {
        [Fact]
        public void CreateInstance()
        {
            Angle.FromDegree(180).Is(Angle.FromRadian(Mathf.PI));
            Angle.FromRadian(-4 * Mathf.PI).Is(Angle.FromDegree(-720));
            Angle.FromDegree(-1, -180).Is(Angle.FromDegree(-360 + -180));
            Angle.FromRadian(-10, Mathf.PI).Is(Angle.FromDegree(-3600 + 180));
            Angle.Zero.Is(Angle.FromRadian(0));
            Assert.ThrowsAny<ArithmeticException>(() => Angle.FromDegree(float.NaN));
            Assert.ThrowsAny<ArithmeticException>(() => Angle.FromDegree(float.NegativeInfinity));
            Assert.ThrowsAny<ArithmeticException>(() => Angle.FromDegree(float.PositiveInfinity));
        }

        [Fact]
        public void Normalize()
        {
            Angle.Zero.Normalize().Is(Angle.Zero);
            Angle.FromDegree(180).Normalize().Is(Angle.FromDegree(180));
            Angle.FromDegree(270).Normalize().Is(Angle.FromDegree(-90));
            Angle.FromDegree(360).Normalize().Is(Angle.FromDegree(0));
            Angle.FromDegree(360 * 4 + 20).Normalize().Is(Angle.FromDegree(20));
            Angle.FromDegree(-360 * 80 + 20).Normalize().Is(Angle.FromDegree(20));
        }

        [Fact]
        public void PositiveNormalize()
        {
            Angle.FromDegree(0).PositiveNormalize().Is(Angle.Zero);
            Angle.FromDegree(180).PositiveNormalize().Is(Angle.FromDegree(180));
            Angle.FromDegree(270).PositiveNormalize().Is(Angle.FromDegree(270));
            Angle.FromDegree(360).PositiveNormalize().Is(Angle.FromDegree(0));
            Angle.FromDegree(380).PositiveNormalize().Is(Angle.FromDegree(20));
            Angle.FromDegree(-90).PositiveNormalize().Is(Angle.FromDegree(270));
            Angle.FromDegree(-360 - 90).PositiveNormalize().Is(Angle.FromDegree(270));
            Angle.FromDegree(-360 * 5 + 90).PositiveNormalize().Is(Angle.FromDegree(90));
        }

        [Fact]
        public void Reverse()
        {
            Angle.Zero.Reverse().Is(Angle.Zero);
            Angle.FromDegree(45).Reverse().Is(Angle.FromDegree(-315));
            Angle.FromDegree(-90).Reverse().Is(Angle.FromDegree(270));
            Angle.FromDegree(180).Reverse().Is(Angle.FromDegree(-180));
            Angle.FromDegree(360).Reverse().Is(Angle.FromDegree(-360));
            Angle.FromDegree(359).Reverse().Is(Angle.FromDegree(-1));
            Angle.FromDegree(361).Reverse().Is(Angle.FromDegree(-1, -359));
            Angle.FromDegree(-450).Reverse().Is(Angle.FromDegree(360 + 270));
            Angle.FromDegree(2, 90).Reverse().Is(Angle.FromDegree(-2, -270));
        }

        [Fact]
        public void SignReverse()
        {
            Angle.Zero.SignReverse().Is(Angle.Zero);
            Angle.FromDegree(60).SignReverse().Is(Angle.FromDegree(-60));
            Angle.FromDegree(-120).SignReverse().Is(Angle.FromDegree(120));
            Angle.FromDegree(-2, 60).SignReverse().Is(Angle.FromDegree(2, -60));
        }

        [Fact]
        public void Absolute()
        {
            Angle.Zero.Absolute().Is(Angle.Zero);
            Angle.FromDegree(60).Absolute().Is(Angle.FromDegree(60));
            Angle.FromDegree(-120).Absolute().Is(Angle.FromDegree(120));
            Angle.FromDegree(-4, 60).Absolute().Is(Angle.FromDegree(4, -60));
            Angle.FromDegree(4, -60).Absolute().Is(Angle.FromDegree(4, -60));
        }

        [Fact]
        public void StandardMethods()
        {
            Angle.FromDegree(3, 270).ToString().Is("3x + 270°");
            Angle.FromDegree(90).Equals(Angle.FromDegree(1, 90).Normalize()).IsTrue();
            Angle.FromDegree(45).Equals(45).IsFalse();

            object o = Angle.FromDegree(135);
            Angle.FromDegree(135).Equals(o).IsTrue();
            Angle.Round.Equals(null).IsFalse();
        }

        [Fact]
        public void Operator()
        {
            (Angle.FromDegree(45) + Angle.FromDegree(90)).Is(Angle.FromDegree(135));
            (Angle.FromDegree(30) - Angle.FromDegree(90)).Is(Angle.FromDegree(-60));
            (Angle.FromDegree(90) * 4.5f).Is(Angle.FromDegree(90 * 4.5f));
            (Angle.FromDegree(45) * -1).Reverse().Is(Angle.FromDegree(315));
            (Angle.FromDegree(90) * 0).Is(Angle.Zero);
            (Angle.FromDegree(4, 90) / 2).Is(Angle.FromDegree(2, 45));
            (Angle.FromDegree(5, 10).Normalize() == Angle.FromDegree(10)).IsTrue();
            (Angle.FromDegree(-5, 10).PositiveNormalize() == Angle.FromDegree(10)).IsTrue();
            (Angle.FromDegree(90).Reverse() != Angle.FromDegree(-90)).IsTrue();
            (Angle.FromDegree(45) > Angle.FromDegree(90)).IsFalse();
            (Angle.FromDegree(-1, 0) > Angle.FromDegree(-360)).IsFalse();
            (Angle.FromDegree(-1, 0) >= Angle.FromDegree(-360)).IsTrue();
            (Angle.FromDegree(-1, 20) < Angle.FromDegree(-360)).IsFalse();
            (Angle.FromDegree(1, 45).Normalize() <= Angle.FromDegree(45)).IsTrue();
            (Angle.FromDegree(1, 45).Normalize() <= Angle.FromDegree(90)).IsTrue();

            Assert.Throws<NotFiniteNumberException>(() => Angle.FromDegree(float.MaxValue) + Angle.FromDegree(float.MaxValue));
            Assert.Throws<NotFiniteNumberException>(() => Angle.Zero - Angle.FromDegree(float.MaxValue) * 2);
            Assert.Throws<NotFiniteNumberException>(() => Angle.Round / 0);

        }

        [Fact]
        private void Getter()
        {
            Angle.FromDegree(2, 90).TotalDegree.Is(810);
            Angle.FromDegree(2, 90).Normalize().TotalRadian.Is(Mathf.PI / 2);
            Angle.Zero.NormalizedDegree.Is(0);
            Angle.FromDegree(2, 90).NormalizedDegree.Is(90);
            Angle.FromDegree(-1, -90).NormalizedRadian.Is(-1 * Mathf.PI / 2);
            Angle.Zero.PositiveNormalizedDegree.Is(0);
            Angle.FromDegree(1, 90).Reverse().PositiveNormalizedDegree.Is(90);
            Angle.FromDegree(-2, 90).PositiveNormalizedRadian.Is(Mathf.PI / 2);
            Angle.FromDegree(3, 90).Lap.Is(3);
            Angle.FromDegree(360).Lap.Is(1);
            Angle.FromDegree(-180).Lap.Is(0);
            Angle.FromDegree(-750).Lap.Is(-2);
            Angle.FromDegree(-360).IsCircled.IsTrue();
            Angle.FromDegree(1, -1).IsCircled.IsFalse();
            Angle.FromDegree(1).IsPositive.IsTrue();
            (Angle.FromDegree(1) * -1).IsPositive.IsFalse();
            Angle.Round.IsTrueCircle.IsTrue();
            Angle.Zero.IsTrueCircle.IsFalse();
            Angle.FromDegree(180).IsTrueCircle.IsFalse();
            Angle.FromDegree(-720).IsTrueCircle.IsTrue();
        }

        [Fact]
        private void Compare()
        {
            var collection = new List<Angle>
            {
                Angle.FromRadian(MathF.PI / 2),
                Angle.FromDegree(45),
                Angle.FromDegree(-90),
                Angle.Zero,
                Angle.Round
            };

            collection.OrderBy(a => a).SequenceEqual(new List<Angle>
            {
                Angle.FromDegree(-90),
                Angle.Zero,
                Angle.FromDegree(45),
                Angle.FromRadian(MathF.PI / 2),
                Angle.Round
            }).IsTrue();
            collection.Max().Is(Angle.Round);
            collection.Min().Is(Angle.FromDegree(-90));
        }
    }
}

テスト結果

すべて合格。
image.png

最後に

角度の扱いは簡単なようで地味に厄介でした。
その厄介な部分を全部隠蔽してきれいな部分だけを外部に公開したこのAngle構造体、少し使ってみたのですが普通に便利です。(自画自賛)
みなさんも自己責任でよかったらどうぞ。

以下GitHubでも公開しています。
https://github.com/yutorisan/UnityUtility/blob/develop/UnityUnility/Structs/Angle.cs

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

[初投稿]

自己紹介

現在就活真っ只中の京都大学大学院修士一年(M1)です。
ゲーム業界を目指しています。ポートフォリオの作成が義務感にかられて、なかなか進まずでしたが、友人にこんなゲーム作って欲しいんだけど、と言ってもらって作っている際にとても楽しくて、今はゲーム業界が第一志望です。自身のアウトプットの場としてQiitaにゆるくあげていけたらなと考えています。

よろしくお願いします。

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

[Unity]AssemblyDefinitionを跨いだ共通クラスの作成

前提
・Assetをインポートしすぎて60個くらいAssemblyDefinitionできちまったぜ!てへっ!
・グローバルなDebugクラスはHighBridRender(ver8.2.0)で定義されててこっちで定義するとビルドエラー!うへっ!
 ⇒やめてほしい
・AssemblyDefinitionに共通クラスを追加するの面倒だな(今後も含めて)
・ECSとか何万個のオブジェクトをゴリゴリ動かすから少しでも負荷になることは避けたい…ンゴ。

やることは大まかに以下の通り。
<手順>
1.クラスライブラリ(DLL)を作成。
 ⇒VisualStudioで適当にプロジェクト作ってビルドでおk
2.クラスライブラリをAssets/Pluginsのどこかにおく。
 ⇒Assets/Plugins/DLLとかネストしてておk
3.どこででも1.で作成したクラスライブラリが使えるようになる。
 ⇒\(^o^)/やた

<メリット>
・AssemblyDefinitionにわざわざ追加しなくていい。
 [Plugins]⇒[各AssemblyDefinition]⇒[Assembly-CSharp]の順で参照されるらしい。詳しくは知らんが動く。
 #最初はクラスライブラリをUnityのクラスライブラリ群に突っ込んだりしていろいろ試してた。
 [Plugins]にAssemblyDefinition作って置けばいいんじゃね?っていってもできんかった。
 ワイももっと楽がしたかった

<デメリット>
・クラスライブラリのビルド忘れとかがある場合がある。
・クラスライブラリから[Assembly-CSharp]とか[各AssemblyDefinition]の参照ができない。
 ⇒1回目はプレイボタンをエラーなく押すと動くけど、2回目以降は参照がおかしいとUnity様に怒られる。
  バグだと思っているが直るの待つのは愚策なので参照できないと割り切って作る。
  クラスライブラリなので処理を独立させましょう。

<例>
DebugLog用のオーバーラップクラスでいい感じに作れるんじゃないかと思われる。

DebugS.cs
// 1.共通クラスライブラリ(DLL)
//  ネットのどこかにあるDebugLogのオーバーラップクラス
using UnityEngine;
public static class DebugS
{
#if !ENABLE_DEBUG_MODE
    private const string CONDITIONAL_TEXT = "UNITY_EDITOR";
#else
    private const string CONDITIONAL_TEXT = "ENABLE_DEBUG_MODE";
#endif
    /* 略 */
    [Conditional(CONDITIONAL_TEXT)]
    public static void Log(object message, LogLevel level)
    {
        if ((int)Ground.In.Value >= (int)level) UnityEngine.Debug.Log(message);
    }
    /* 略 */
}
namespace Ground
{
    public enum LogLevel { None,Low,Mid,High}
    public class In {public static LogLevel Value = LogLevel.None;}
}

使用例(上記DLLを[Assets/Plugins/DLL]とかに配置後)

Hogehoge.cs
// UnityのどこかのDebugLog出力ソースコード
// 参照設定やUsingは不要(でーん!)
protected override void Initialize()
{
    if (!LoadThisData(SettingsSavePath))
    {
        DebugS.LogError($"Fail Load {SettingsSavePath}");
    }
    if (AutoSaveFlag)
    {
        StartAutoSave(DuloGames.UI.UIManager.Instance);
        DebugS.Log("AutoSave", Ground.LogLevel.High);
    }
    else DebugS.Log("Not AutoSave", Ground.LogLevel.High);
}
// ログ出力レベルの変更
void ChangeDebugLoglevel(Ground.LogLevel level)
{
    Ground.In.Value = level;
}

Unity標準のDebugクラスがオープンソースコードだったら喜んでたけど探してもなかったのであきらめた
(オーバーラップしたかった)

これが一番っょぃと思います。

元のDebugぶっ壊せてもユーザ環境壊せんから仕方ないな、これで勘弁してやろう。
俺より強いやつに会いに行く。(いまここ)

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

Unityで行動分析

はじめに

 「誰でも手を出せる・共有できる・改善できる」をモットーに、「Unity×行動分析」をUnity初心者でもわかりやすく記事にしてゆこうと思います。今回は、今後の記事内容について書いておきます。もしリクエストがあれば、いつになるかはわかりませんが、そちらにも対応したいと思います。

今後の予定

 強化スケジュールをUnityで作る方法を2月中に記事にする予定です。また、応用編として、ルール支配行動の実験や自由オペラント実験ができるような課題の作り方、分析方法についても3月中に記事にできればなと考えています。

その1:時隔スケジュール編

 その1では、「時隔スケジュール(interval schedule)」の中の、「固定時隔スケジュール(Fixed Interval schedule)」と「変動時隔スケジュール(Variable Interval schedule)」をUnityで作る方法の解説を行います。
URL: 2月中に投稿予定

その2:比率スケジュール編

 その2では、「比率スケジュール(ratio schedule)」の中の、「固定比率スケジュール(Fixed Ratio schedule)」と「変動比率スケジュール(Variable Ratio schedule)」をUnityで作る方法の解説を行います。
URL: 2月中に投稿予定

その3:時間スケジュール編

 その3では、「時間スケジュール(time schedule)」の中の、「固定時間スケジュール(Fixed Time schedule)」と「変動時間スケジュール(Variable Time schedule)」をUnityで作る方法の解説を行います。
URL: 2月中に投稿予定

その4:反応率分化強化スケジュール編

 その4では、「反応率分化強化スケジュール」の中の、「低反応率分化強化(Differential Reinforcement of Low rates)」と「低反応率分化強化(Differential Reinforcement of High rates)」をUnityで作る方法の解説を行います。
URL: 2月中に投稿予定

その5:分析編

 その5では、得られたデータをPythonで分析する方法の解説を行います。
URL: 3月中に投稿予定

その6:ルール支配行動編

 その6では、教示による制御の実験研究ができるような課題をUnityで作る方法の解説を行います。
URL: 3月中に投稿予定

その7:オペラント実験箱編

 その7では、自由オペラント実験が行えるような、疑似オペラント実験箱をUnityで作る方法の解説を行います。
URL: 3月中に投稿予定

さいごに

 需要あるのか? という感じですが、暇つぶしがてらカタカタ書いてゆきます。来年度には間に合う予定なので、興味のある方はお付き合いください。 やる気が続けばいいなぁ…

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

OculusQuest2にアプリを入れようとしたときに起こったこと

プロジェクトビルド時のエラー

新規プロジェクト作成し、buildsettingでplatformをandroidに変更し、buildしたが下記のようなエラーが出た。

Access is denied.
UnityEngine.GUIUtility:ProcessEvent(Int32, IntPtr)

セキュリティソフトがjava.exeをブロックしていたのでホワイトリストに追加すると、ビルド通った。

Oculus Integratonをimportする際、PCがブルースクリーンになった。

  • ブルースクリーンになったが、文字が左上に一文字だけ表示されているだけのブルースクリーンだった。
  • C:\Windows\Minidumpにはダンプファイルは生成されておらず、 C:\Windows\memory.dmpも更新日付が去年のままで更新されていなかった。
  • chromeのハードウェアアクセラレーションをオフにしてやると直ったが、本当にハードウェアアクセラレーションが関係あるかどうかは分からない。
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

STYLY (Unity+Playmaker) でインタラクティブミュージック

TL; DR(数行まとめ)

  • VR作品公開プラットフォーム STYLY 上でインタラクティブミュージックを行う方法(の一例)を解説します.私たちの作品 Birds of a Scape での実装を具体的に解説します.今回実装したのは以下の2つです.
    • アクションや視線方向に応じた「縦の遷移」
    • いつ鳴らされても BGM と協和する効果音

はじめに

こんにちは,エンジニアの yos1up です.最近,趣味活動で VR 作品の制作に関わり始めたのですが1,とても楽しいので,技術的なことを記事化しておこうと思いました.

VR 作品を公開できるプラットフォームの一つに STYLY があります.今回私たちは,STYLY 作品を応募できるコンペ NEWVIEW AWARDS 2020 に向けて, VR 作品 Birds of a Scape を制作して応募しました2.この作品のコンセプトは「インタラクティブに光と音の景色を作っていく」というもので,私はこの中のインタラクティブミュージックの実装を担当しました.VR 作品およびインタラクティブミュージックの実装は初めてだったのですが,非常に楽しいものでした.

STYLY と Unity と Playmaker の関係

VR 作品の制作は初めてでしたが,STYLY 上の VR 作品の制作は Unity で完結できる3ため,Unity に習熟してさえいれば誰でもすぐに制作を始められるようになっています.ただし一つ大きな制約があり,Unity のスクリプト(C# or JavaScript)は STYLY にアップロードすると動作しません.その代わりとして,STYLY は Playmaker に対応しています.Playmaker は,Unity Editor 上でビジュアルプログラミングを可能にする Unity のパッケージです.Playmaker を用いて実装されたロジックは,STYLY にアップロードしても動作します4

したがって,STYLY 上の作品でインタラクティブミュージックを実現するには,Unity+Playmaker でインタラクティブミュージックのロジックを実装する必要があり,これにはいくらかの試行錯誤や困難がありました.そこでこの記事では,作品 Birds of a Scape の中で実装したインタラクティブミュージックのロジックに触れつつ,Playmaker(の標準機能)でインタラクティブミュージックを行う方法を解説していきます.

もくじ

1. Playmaker の標準機能でインタラクティブミュージックを行うための準備

今回メインのシーン用に作成した曲はこちらになります.

スクリーンショット 2021-01-12 15.31.02.png

この曲は多数のトラック(楽器・パート)からなります.これらのトラックは,予めばらして別々の音声ファイルとして用意しておきます(これらの音声ファイルを同時再生すると,もとの曲が聞こえる).

縦の遷移をするために

インタラクティブミュージックの手法の中でも「曲を構成する全トラックのうち,一部のトラックのオン・オフを切り替えたり,トラック間の音量バランスを変更したりする」ことによって聴こえ方を変化させるような手法は,縦の遷移と呼ばれます.今回,この縦の遷移を Playmaker で実装するため,以下の方針をとりました.

  • 全てのトラックは予め別々の AudioSource に読み込んでおき,曲の再生を開始したいタイミングで,(初めの時点ではオフにしておきたいトラック含め)全ての AudioSource を同時に再生開始します.

    • 全トラックを同時に再生開始するタイミングは,他の処理が混み合うタイミング(例えばシーン読み込み完了直後など)とは意図的にずらした方が良いです.処理が不安定な最中に全トラックを一斉に再生開始すると,全てのトラックが縦に揃わずに,数十 ms 程度ずれて再生されてしまうことがありました.
  • トラックのオン・オフは,トラック音量の設定により実現します.

    • 音量を 0 に設定することで,トラックをオフにできます.

後で解説する「トラックをオンにされた直後だけ一時的に音量を大きめにする機能」や「視線のY方向に応じて音量調整する機能」など,トラック自身にいくつかの機能を持たせたかったので,単一のトラックを管理できる Audio GameObject を作成し,その上の Playmaker FSM としてそれらの機能を実装しました.(その上で,この GameObject を Prefab 化しておき,シーン開始時にトラックの数だけこの Prefab をインスタンス化するように実装しました.)トラックの FSM は以下のようになっています.

スクリーンショット 2021-01-20 16.12.30.png

  • 初期化:WaitingForStateBGM で待機します.グローバルイベント StartBGM が送られると,PlayingBGM ノードへ遷移し,そこで Audio Play アクションが実行され,その後 ComputeAdjustedVolumeContinuously ノードへ遷移します.

  • 音量の状態を毎フレーム管理する:ComputeAdjustedVolumeContinuously ノードの処理は,いずれも毎フレーム実行する設定にしてあります(Every Frame チェックボックスをオンにしています).毎フレーム,トラックの音量を, (基本の音量) * (視線のY方向に依存した係数) * (オン直後の一時的な音量調整のための係数) により計算します.

    • 基本の音量:ベースとなる音量設定です.外部から値を代入されることで,基本の音量が変化します(この FSM 内のロジックで値が書き換えられることはありません).
    • 視線のY方向に依存した係数:毎フレーム,カメラオブジェクトの方向ベクトルのY成分の一次関数により計算します(後述).
    • オン直後の一時的な音量調整のための係数:初期値は 1.0 ですが, 1.0 以外の値となっている時には,毎フレーム 1.0 に指数関数的に近づいていくように計算されます5.このトラックをオンにする時に,外部から 1 より大きな値(例えば 1.5)を代入することで,「オン直後だけ音量がちょっと大きく,少し経つと普通になる」挙動が実現できます.

曲再生位置の取得

Playmaker の標準機能には,AudioSource から直接「現在の曲再生位置」を取得するアクションは存在しませんが,それとは別に「シーンスタート時点からの経過時間」を取得するアクションはあるので,これを使って間接的に現在の曲再生位置を求めます.具体的には,上記の全トラック再生開始処理を行う時点で「シーンスタート時点からの経過時間」を取得して変数t0に格納しておき,その後,現在の曲再生位置を取得したくなった時には,改めて「シーンスタート時点からの経過時間」を取得して変数 t1 に格納し,t1 と t0 の差を求めれば良いです.これで,曲再生位置を「秒」の単位で得ることができます.なお,曲再生位置を「秒」の単位ではなく「拍」の単位で知りたい場合は,テンポ (BPM) の値を掛けて 60 で割ることにより「秒数」から「拍数」に変換できます.

注意:STYLY では conditional expression アクションは動作しない

Playmaker には標準で conditional expression という便利なアクションが用意されています.任意の条件式を文字列により指定でき(例えば x / y < z + w のように),その結果に応じた条件分岐をしてくれます.しかし,STYLY は conditional expression アクションに対応していません6.STYLY 上で正常に動作させるには,conditional expression アクションの使用をやめて,その中の条件式そのものを Int/Float Operator アクションや Int/Float Compare アクションなどで実装し直せば OK です7

2. Birds of a Scape で実装した「仕掛け」の紹介

Birds of a Scape は「インタラクティブに景色を作っていく」という体験をコンセプトにした作品です.作中でプレイヤーは,不死鳥とともに夜空を飛行しながら,トリガーアクションにより星空を賑わせ,家々に明かりを灯していき,鳥の仲間を増やしていき,徐々に景色が賑やかになっていきます.(紹介動画

アクションによって曲が盛り上がっていく(アクションに依存した縦の遷移)

本作品では,プレイヤーがコントローラーのトリガーを引くことで,景色が一段階盛り上がります8.この「盛り上がり」が生じる際に,BGM の楽器(トラック)が一種類増えるような演出を実装しました.その際,以下に気をつける必要がありました.

  • 盛り上がりが生じた際に,まだオンになっていないトラックのうち,ちょうど 1 つのトラックだけがオンに変化してほしいです.

  • 多くのトラックは,曲全体にわたって音符が入力されているわけではなく,曲の一部の区間にしか音符が入力されていません.現在の曲再生位置が,そのトラックの「音符が入力されている区間」の外であるときに,そのトラックをオンにしたとしても,直ちに何かが聴こえ始めることはありません.そのような事態は避けたいです.

そこで,以下のようなロジックを実装しました(今回の曲では,景色の盛り上がりに応じて一つずつオンにすることのできるトラックが 12 個ありました.以下の記述では,それらを「トラック1」「トラック2」...「トラック12」と呼んでいます).

景色の盛り上がりが生じたとき
    もし,トラック1がまだオフであり,かつ現在の再生位置がトラック1の「音符が入力されている区間」の内側であるならば
        トラック1をオンにする
        処理を終える
    もし,トラック2がまだオフであり,かつ現在の再生位置がトラック2の「音符が入力されている区間」の内側であるならば
        トラック2をオンにする
        処理を終える
    もし,トラック3がまだオフであり,かつ現在の再生位置がトラック3の「音符が入力されている区間」の内側であるならば
        トラック3をオンにする
        処理を終える
    ...
    もし,トラック12がまだオフであり,かつ現在の再生位置がトラック12の「音符が入力されている区間」の内側であるならば
        トラック12をオンにする
        処理を終える

さらに実際の実装では,以下の工夫を行いました.

  • 上に記した実装だと,12 個のトラックが解禁される順番がどのプレイヤーも全く同じになってしまいます.プレイヤーの行動によって音楽体験が多少は変わってほしいと思い,次のような工夫をしました.実は景色には「空」「鳥」「街」の三系統あり,トリガーを弾いたときの照準の向き次第でいずれか一系統だけが盛り上がるのですが,上記の 12 トラックを 4 トラックずつ 3 つのグループに分け,それらを「空のトラックグループ」「鳥のトラックグループ」「街のトラックグループ」としておき,例えば「空」の系統の景色が盛り上がった時は「空のトラックグループ」内のトラックのうち一つをオンにする,「鳥」の系統の景色が盛り上がった時は「鳥のトラックグループ」内のトラックのうち一つをオンにする
    (街についても同様)という実装を行いました.これにより,プレイヤーのアクションによってトラックが解禁される順番が前後する余地が生まれました.

  • トリガーを引いてトラックが一つオンになったにも関わらず.それにプレイヤーが気づかない,というケースが多くありました.そこで「トラックがオンにされた直後,そのトラックの音量は他のトラックよりも大きな値であり,そこから時間とともに,他のトラックと等しい音量まで減衰していく」という実装を行いました.

  • トラックを次々オンにしていくと,次第に音割れが生じてしまいました.そこで,オンになっているトラック数に応じて,全トラックの基本の音量を動的に変更することを考えました.

実際の Playmaker の FSM は,以下のようになっています.

スクリーンショット 2021-01-12 15.01.54.png

  • 初期化: GenerateTracks ノードで,各トラック用の GameObject を,前述の Prefab から生成します.その後,Init ノードで曲の開始まで待機します.

  • 曲の開始:StartBGM というグローバルイベントが送られてくると,(この FSM ではなく)各トラックに付随する FSM によって各トラックの再生が開始します.と同時に,(この FSM の)SetBGMStartTime ノードにて,BGM 開始時刻が変数に記録されます.その後,WaitingEvents ノードに遷移します.

  • 待機状態: WaitingEvents ノードで待機しています.景色の盛り上がりが生じると,SkyEnhanced, BirdEnhanced, TownEnhanced のうちいずれかのグローバルイベントが送られてきますが,それらに応じて,右下エリアのノード群に状態遷移します.右下エリアでは,上述のアルゴリズムに従って,オフからオンに変化するトラックが決定されます.

  • 全パートの音量を更新:ControlAllActiveTracksVolume ノードにて,全トラックの基本の音量を調整しています9.オンになっているトラックの一覧が格納されている Array があるので,その要素を Array Get Next アクションを用いてイテレートしています.全要素をイテレートし終わったら,WaitingEvents ノードに戻ります.

空を見上げた時だけ音が響く(視線方向に依存した縦の遷移)

星空がとても広い空間であることを演出するために,星空を見上げた時にのみ BGM に残響が生じるようにしました.
これは,ベーストラック(最初から最後までずっとオンのトラック)の残響部分だけのトラックを用意しておき(これも他の全てのトラックと同時に再生開始しておく),その音量を毎フレーム適切な値に更新する,という方法で実現しました.音量の値は,視線方向ベクトルのy成分(上方向が正)の値についての一次関数にしました10
この機能は,各トラックの FSM 上に実装しました.上述の一次関数の「1 次の係数」と「0 次の係数」を FSM のパラメータとして外部から設定可能な形で実装しました.前者を 0 としておけば「視線方向に依存しない固定音量のトラック」となります.

なお「視線方向ベクトルの y 成分」は,以下のアクションにより取得できます(変数 mainCamDirY に -1 以上 1 以下の値が格納されます.真下が -1,水平方向が 0,真上が 1 となります).

スクリーンショット 2021-01-20 16.14.21.png

プレイヤーのアクション次第で曲の行き着く先が変わる(縦の遷移を利用したマルチエンディング)

今回の曲では,終盤のある時点からドラムが鳴り始めますが,その際に「2 種類用意されているドラムトラックのうちどちらがオンになるか」が,それまでのプレイヤーの行動に依存して決まるように実装しました.曲の再生位置が所定の拍数に達するとイベントが生じ,その時点で「プレイヤーのそれまでの行動内容を反映するある変数についての条件」によっていずれか一方のトラックのみがオンに切り替わります.

Playmaker の FSM 上では, 所定の拍数に到達したタイミングで TimeToDetermineDrum イベントが発火し, DetermineDrum ノードへ遷移します.それまでのプレイヤーのアクションに依存して PreferSky または PreferTown イベントが発火し,遷移先の Switch(Timpani|Drummachine)On ノードにて,いずれかのドラムトラックがオンとなります.なお,TimeToDetermineDrum イベントが繰り返し発火しないように,一度発火した時点で専用のフラグが立ち,そのフラグが立っている場合には TimeToDetermineDrum イベントが発火しないように制御しています.

アクション効果音が音楽と協和する(音楽再生位置に依存した効果音の再生)

トリガーを引くたびに鳴る効果音が,その時点に流れている曲と協和するような仕組みを実装しました.効果音はピチカートの和音で作っていますが,この和音が,その時点での曲のコードと調和するようにしました.具体的には,現在の再生位置(拍数)から現在の曲のコード11を調べ,その結果に応じて効果音を鳴らし分けました.さらに,コードが変化しない短時間の間に複数回トリガーを引いても色々な音が鳴るようにするため,各コードに協和する効果音音声を(1 通りではなく)4 通り用意し,その中からランダムに 1 つを再生するようにしました12

スクリーンショット 2021-01-12 15.01.35.png

  • 初期化: Init3 ノードで曲の開始まで待機します13

  • 曲の開始:StartBGM というグローバルイベントが送られてくると,(この FSM ではなく)各トラックに付随する FSM によって各トラックの再生が開始します.と同時に,(この FSM の)SetBGMStartTime ノードにて,BGM 開始時刻が変数に記録されます.その後,WaitingForSounding ノードに遷移します.

  • 待機状態: WaitingForSounding ノードにいます.コントローラーのトリガーが引かれると,まず FindingController(R|L) ノードでコントローラーオブジェクトを取得します.そして ComputingTargetPos ノードで「効果音を発生させる空間位置」を計算します.コントローラーから出る光線エフェクトの先端あたりから効果音を発生させたかったので,(コントローラー座標)+(定数)*(コントローラーの方向ベクトル)という計算を行っています.

  • BGM の再生位置を拍数で取得:ComputingBGMPosition ノードにて,現在 BGM 開始から何拍目であるかを求めています.

  • 効果音の音量を決定:はじめのうちは効果音の音量を一定にしていたのですが,それだと,まだ曲が大して盛り上がっていない時には効果音がうるさく感じられ,一方で,曲がすっかり盛り上がりきってしまった時には効果音がほとんど聴こえない,という事態になってしまいました.そこで,曲の再生位置に依存して効果音の音量を決定するようにしました.DetermineVolume ノードにて,曲の静かな部分かそうでない部分のいずれであるかを決定し,それに応じて SetVolume(0|1) ノードで定数の音量を設定しています.

  • 現在再生中のコードを計算:Sounding ノードで,現在の拍数から再生中のコードを計算します.曲のコード進行をまるごと格納した配列を用意しておいてそれを参照するだけの方法でも良かったのですが,配列を打ち込むのが面倒だったのとコード進行の大部分が繰り返しだったため,モジュロ演算と条件分岐を使って拍数からコードを求めるロジックを組みました.一部,特殊なコード進行をする箇所については SoundingInSpecialChordSeq(1|2) ノードで処理を行っています.

  • 所定のコードの効果音を鳴らす:画像右側にある各コード名のノードで,それぞれのコードの効果音を鳴らす処理を行います.Array Get Random アクションで AudioClip を取得し,Audio Play アクションを用いてその AudioClip を再生します(詳細は前述の脚注をご覧ください).

おわりに

最後までお読みくださりありがとうございました. STYLY での作品制作に関する情報源の一つとしてお役に立てれば幸いです.


  1. toidcoder というグループで,3人のメンバーで活動をしています. 

  2. ありがたいことにこの作品は非常に高い評価をいただき,GOLD PRIZE を受賞しました. 

  3. 具体的には,Unity の Scene として開発します.できあがった Scene は,簡単操作で STYLY にまるっとアップロードできます. 

  4. Playmaker には,標準で使える機能の他に,有志が開発した拡張機能を追加でダウンロードして使える仕組みが備わっています.しかしそれらは STYLY のサポート外のため,私たちは Playmaker の標準機能のみを用いて実装しました. 

  5. 実際には,毎フレーム x += (1.0 - x) * 0.02 を実行しています(x: 「オン直後の一時的な音量調整のための係数」).これにより, x は 1.0 へと指数関数的に近づいていく挙動となります.  

  6. 具体的には,conditional expression アクションが実行されようとするタイミングで,その FSM の遷移は停止してしまいます.なお,2020 年 10 月時点の情報です.現在はどうかわかりません. 

  7. ただし,余りを求める演算 x % y は Operator アクションでは実行できないので,代わりに x - floor(x / y) * y と計算する必要があります.なお,floor(...) は「...以下の最大の整数」の意味です.これには Set float to int アクションを利用します. 

  8. 実際にはトリガーにはクールタイムが設定されており,仮にトリガーボタンを連打し続けたとしても,景色が盛り上がる効果が生じるのは数秒に一度となります. 

  9. 元々は,オンになっているトラックの個数に応じて動的に基本の音量を設定しようと思っていたのですが,実際にやってみるとどういうわけかイマイチだったため,最終的には全てのトラックに 0.38 という(定数の)音量をセットしているだけの実装にしました. 0.38 という値は,全トラックをオンにしても音割れしないギリギリの値を探った結果の値です. 

  10. 一次関数の結果はときに(音量として許される)[0, 1] の範囲の外となることがありますが,実際には [0, 1]の範囲に勝手にクランプされます(たしか). 

  11. 今回の曲では D♭, E♭, Fm, Cm, A♭sus4, A♭, B♭m, Csus4, C, G♭, E♭m のうちのいずれかです. 

  12. Array Get Random アクションを利用しました(連続して同じサンプルを引かないオプションもオンにしました).例えば D♭ の効果音の AudioClip が 4 つありますが,これらを長さ 4 の配列に(予め静的に)格納しておき,その配列から Array Get Random アクションにより 1 つの AudioClip を取得し,それを Audio Play アクションで再生することで「D♭ 効果音の再生」が実現します.なお,ランダムな音を再生することに特化した Play Random Sound アクションもありますが,このアクションでは,動的に生成される AudioSource のプロパティをこちらで変更できず,(今回の用途ではオフにしたい)ドップラー効果をオフにできなかったため,使用を諦めました.対照的に,Audio Play アクションでは,再生に用いる AudioSource をこちらで自由に選択できるので,予めドップラー効果をオフにした AudioSource の Prefab を静的に用意しておき,それを毎回 Create Object して,生じた GameObject を選択すれば OK です. 

  13. Init1, Init2 ノードでは,プレイヤーの頭の位置に相当するカメラオブジェクトを取得していますが,実際にはこの FSM ではカメラの位置を特に使用していないので,不要です. 

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

当たったときに複製を生成する【テンプレスクリプト】

下記スクリプトがアタッチされたオブジェクトは、
衝突判定時に、コンポーネントでアタッチされたオブジェクトを作成する。

  • アタッチするオブジェクトがパーティクルの場合は、パーティクルが都度生成される。
  • パーティクルの場合は、生成されたクローンがそのまま残ってしまうので、パーティクルシステム内のコンポーネントで、「Stop Action」を「Destroy」にすればよい。
particleSpawn.cs
using UnityEngine;

public class particleSpawn : MonoBehaviour
{
    public GameObject particle;

    private void OnCollisionEnter(Collision col)
    {
        Instantiate(particle.gameObject, this.transform.position, Quaternion.identity);
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【Instantiate関数】当たったときに複製を生成する【テンプレスクリプト】

下記スクリプトがアタッチされたオブジェクトは、
衝突判定時に、コンポーネントでアタッチされたオブジェクトを作成する。

  • アタッチするオブジェクトがパーティクルの場合は、パーティクルが都度生成される。
  • パーティクルの場合は、生成されたクローンがそのまま残ってしまうので、パーティクルシステム内のコンポーネントで、「Stop Action」を「Destroy」にすればよい。
particleSpawn.cs
using UnityEngine;

public class particleSpawn : MonoBehaviour
{
    public GameObject particle;

    private void OnCollisionEnter(Collision col)
    {
        Instantiate(particle.gameObject, this.transform.position, Quaternion.identity);
    }
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

interfaceについて!!Unity日記#1

始めに!!

・皆さん初めまして!!とある学校で専門生してるamanon(仮名)と申します!
 Unityを勉強してる皆様と情報共有できたらいいな~と思って初めてみました!

・Unity初心者さんや勉強中な人向けに記事を書いていきますのでよろしくお願いします!!

・第一回目はinterfaceです!(1回目にやる内容じゃないですが...(-_-;))
 とても便利なので頑張って行きましょうー!!

interfaceとは?

・クラスを作った人(作成側)とそのクラスを利用する側(利用者)でお約束事が出来る機能です

interfaceの用途

・コードの疎結合化 

・実装の中身は利用者側は知らなくていい!

・メソッドの中身は利用者側が仕様を決めれることが出来ます!

まとめてみましたが全然理解しなくて大丈夫ですよ!!
言葉だけじゃ伝わらない部分もあるのでソース交えながらやっていきましょう!!

使い方

public interface I_Damageble()
{
 //OK!!
  public  void Damage(int Value);

  //エラー
  public  void Damage(int Value)
 {
   helth-=Value;
  }
}

・今回はI_Damagebleというinterface(作成側)を作りDamageという関数を持たせました!
・interfaceはあくまでも機能だけ定義する事が出来て中身は作る事が出来ません!少し矛盾してるというか変ですよね.
・じゃあ関数なのに中身はどうやって実装するんだ?って思いますよね!そうです、中身は利用者側が実装しなければなりません!作っていきます!

public class Damageble:MonoBehaviour,I_Damageble <-ここに利用するinterface名
{
  //HP
  private int helth=0;
  //ダメージ関数
  public void  Damage(int Value)
  {
   helth-=Value;
  }
}

・Damagebleクラス(利用者)にI_Damageble(作成者)を継承させたらI_Damagebleの機能が使える事が出来ます!
・実際に継承させI_DamagebleのDamage関数の中身を実装してみました!

void OnCollisonEnter(Collison other)
{ 
  //ohter(当たった情報)からIDamagebleを取得します!
  //無い場合nullが返ってきます!
  var id=other.gameobject.Getcopoment<IDamageble>();

  //nullの場合に処理を実行するとIDamagebleの情報が無いのでエラー
  // idにデーターが入った場合のみ実行する,nullの場合は処理しない
    if (id!=null)
    {
       id.Damage(10);
    }
}

・あとはこのDamage関数を当たり判定の関数の中で呼んであげればやる事は終わりです!
・FPSでプレイヤーが建物に当たった時にダメージ処理が実行されたらおかしいですよね。なのでnull処理は必ず入れてください。

ここまで

interfaceの使い方を紹介しましたがどこが便利やねん!普通にダメージ関数を作った方が楽やんけ!!
と思った方もいらっしゃるかと思いますがこれから紹介していきます!!

void OnCollisonEnter(Collison other)
{ 
    if(other.gameobject.tag=="Enemy")
    {
      Damage(10);
    }
}

・おなじみの当たり判定処理ですね!!FPSやTPSなどでは問題ないですが宇宙区間で戦うシューティングを思い浮かべてみてください!

・もし岩や流星群、飛行機の破片が当たった時にダメージ処理したい場合それぞれの
 オブジェクトに"Enemy"というタグを設定しますか?しないですよね(;^ω^)だって敵じゃないですので!

・じゃそれぞれにタグを設定しますか?

void OnCollisonEnter(Collison other)
{ 
    if(other.gameobject.tag=="Enemy")
    {
      Damage(10);
    }
    if(other.gameobject.tag=="ryusei")
    {
      Damage(10);
    }
    if(other.gameobject.tag=="hahenn")
    {
      Damage(10);
    }

    if(other.gameobject.tag=="iwa")
    {
      Damage(10);
    }
}

・いい案ではありますがhit時ダメージ処理をしたいオブジェクトが増えるたびにコードも増えてバグの原因になりかねません。
・チーム制作なんかでやっているとヒューマンエラーにもつながります。

解決法

・ここで便利になってくるのがinterfeceです!

//流星クラス
public class ryusei:MonoBehaviour,I_Damageble 
{
   public void Damage(int value)
   {
   }
}

//岩クラス
public class iwa:MonoBehaviour,I_Damageble 
{
   public void Damage(int value)
   {
   }
}

//欠片クラス
public class kakera:MonoBehaviour,I_Damageble 
{
   public void Damage(int value)
   {
   }
}

//プレイヤークラス
public class Player:MonoBehaviour
{
   void OnCollisonEnter(Collison other)
  { 
  //これだけで欠片、岩、流星が当たった時の処理が出来ます
  var id=other.gameobject.Getcopoment<IDamageble>();
    if (id!=null)
    {
       id.Damage(10);
    }
  }
}

・それぞれのクラスにinterfaceを背負わせる事で当たったときそのオブジェクトは
interfaceを背負ってるか背負ってないかでダメージ処理が出来るので
わざわざ文字列で判定をとらなくていいですしコードも少なくて済みます!とても便利ですよね!!!

以上です!!お疲れ様でした!

おわりに

ここまで目を通してくださりありがとうございました!当たり判定をいっぱい取る場合には有効な手法ですので使ってみてください!!

初めての試みで不慣れですがまた定期的に書いていこうと思います!よろしくお願いします

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

【マウスカーソルの初期化】FPSゲームのエンドSceneでマウスカーソルを有効化する【テンプレスクリプト】

下記スクリプトがアタッチされたSceneでは、
マースカーソルの状態が初期化されて表示されます。
主に、FPSゲームなどの「マウスカーソルが非表示で、
マウスでプレイヤーの首振り」をしているゲームなどで、その後のScene遷移の際に使われる機会が多いと思います。

cursorFormat.cs
using UnityEngine;

public class cursorFormat : MonoBehaviour
{
    void Update()
    {
        Cursor.lockState = CursorLockMode.None; //マウスカーソルの設定を初期化して、
        Cursor.visible = true; //マウスカーソルを表示する
    }
}

こちらの記事もどうぞ

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

直近のUnity開発で困ったことの逆引きメモ

メモ書きですが、どなたかのお役に立てれば幸いです。

NavMesh

NavMeshAgentのキャラクターの動きを改善する

こちらを使わせていただくとイイカンジに動きます。感謝!!
NavMeshAgentでよい感じにキャラクターを歩かせる - tanaka's Programming Memo

NavMeshをランタイムで動的に生成(Bake)する

NavMesh Componentsを入れるとNavMesh Surfaceが使える。
これをNavMeshを生成させたいオブジェクトに入れ、任意のタイミングで定期的にNavMeshをBakeさせればよい。
しかし、定期的にBakeすると、その都度Agent(キャラクター)の座標が変わってしまうことがあるので注意。

Unity:動的NavMeshの確認 - simplestarの技術ブログ

NavMeshAgentで浮いてしまう問題

[Navigation]ウィンドウの[Bake]→[Advanced]→[Height Mesh]のチェックをオンにする

Dynamically Fixing NavMeshAgent Base Offset? - Unity Answers

また、モデルの配置時は最低でもNavMeshが生成された地面等が必要。
地面がないと空中に浮いたまま移動してしまう。

UI

UIを貫通してRaycastしてしまうのを防止する

下記はARRaycastmanager向けのものだが、汎用的に使えそう。
なおPhysics.Raycastは常にUIを貫通するので注意。

AR Foundation ARRaycastmanager ray passing through UI,AR Foundation - ARRaycastmanager ray passing through UI - Unity Answers

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

UnityEditor.Graphs.Edge.WakeUp ()

Unityで発生するエラー
再起動で解決。
必ずSAVEしてから再起動すること

UnityEditor.Graphs.Edge.WakeUp ()

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