20201130のUnityに関する記事は16件です。

Unity絡みのメモ

Unity絡みでキレ散らかしたり、時間食われた内容を都度都度メモってく。

Unityでビルドした後、VisualStudio上でのキー入力の挙動がおかしくなる

【環境】
VisualStudio 2019
Unity 2020.1.13
【状況】
ソースコード内を選択し文字入力箇所がコード内であっても、十字キーやBackSpaceキー、そしてctrl+cやctrl+sなどのショートカットキーが効かない。
(例:十字キー左を押すとなぜかソースコードの拡大率の部分の編集になる)
なお、ツール→オプション→環境→キーボード→リセットも効果なし。
【対処】
方法1.VisualStudioを一度落として再度立ち上げる。
方法2.別ソースコードが開かれている場合、編集ソースコードを切り替える。(その後元のコードに戻ると直ってる)

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

Unity WebGL C# <=> JS (jslib)

Unity (#1) Advent Calendar 第1日目を飾るにはものすごくふさわしくない超地味な内容となっています。申し訳ございません。

以前に
Unity(WebGL)でC#の関数からブラウザー側のJavaScript関数を呼び出すまたはその逆(JS⇒C#)に関する知見(プラグイン形式[.jslib])
という長ったらしいタイトルの記事を書きましたが、今回はこれの更新版+αという内容となっています。
前回の記事はもう古くなってしまったので、改めて調査をしました。
(古い記事は一応古いバージョンとして残しておきます)

調査したUnityのバージョン: 2019.4.1f1 (と2020.2.0b2.3094)
(文中ではそれぞれ 2019, 2020 と省略して表記します)

どうしてもES6+でコードが書けない

なんかemscriptenの最新バージョンだとES6+でコードが書けるようになったとかならないとか。ただしUnityで使用されているemscriptenではいまだ(Unity 2020)にES6+でコード書くと怒られます。

同じ関数名の関数はどちらかが上書きされる

複数の.jslibを用意してコンパイルすると、同じスコープ(ブロック)に展開されます。
なので、別々の.jslibファイルであっても同じ関数名の関数を定義した場合、どちらかが上書きされてしまいますので注意が必要です。ですので、名前が被らないような少し長めな関数名にすることがいいでしょう。
(どのような順番で上書きされるのかまでは未調査)
特に、アセットストアにあるWebGL用のアセットをインポートすると高確率で.jslibファイルがありますので、もしかするとこういったアセットの.jslibの関数を上書きしてしまう可能性があることに注意してください。

逆に、これがとても有効に働くときもあります。それがUnity自身が用意している.jslib(実際は.js)の上書きです。
例えば、WebCamTextureのWebGLビルド用ソースはWebCam.jsというファイルになっています。ただ、このWebCam.jsは複数カメラが接続された状態でのカメラの選択などが行えないなどの非情なまでのバグがあります。このWebCam.jsで定義されている関数と同じ関数名で正しく動作する関数を定義した.jslibファイルを用意してコンパイルすればきちんとカメラデバイスを選択できるようになります。
きちんとデバイスを選択できるようにしたサンプル

dynCallパターン

dynCallのパターンが2019では 165パターンに結構増えており、さらに2020においては566パターンとめちゃくちゃ増えてます。
ちなみに、C:\Program Files\Unity\Hub\Editor[version]\Editor\Data\PlaybackEngines\WebGLSupport\BuildTools\Emscripten にあるemscripten-version.txtの内容を見ると2019, 2020ともに"1.38.11"となっており一緒でした。
同じバージョンなのになぜパターン数が違うのかが疑問です。

dynCallデータ型に'j'が追加される(2019~)

データ型に'j'が追加されています。
ドキュメントから引用させていただくと

  • 'v': void type
  • 'i': 32-bit integer type
  • 'j': 64-bit integer type (currently does not exist in JavaScript)
  • 'f': 32-bit float type
  • 'd': 64-bit float type

となっており'j'はBigIntとして扱うのでしょう。
(なぜ'j'なのかをDiscordで聞いてみたら'i'の次だからそうです)
とすると、HEAP64やHEAPU64があるのかと予想しましたが2020でもありませんでした。
このIssueの最後の開発者コメントで、WASM_BIGINTフラグを有効にすることでHEAP64が追加されるということです。
調べてみるとWASM_BIGINTフラグは1.39.13で追加されたもので、試せる環境が手元にないため未検証です。

Runtimeオブジェクトの廃止(2019~)

古いバージョンでは、dynCall()などのメソッドはRuntimeオブジェクトにありましたが、このRuntimeが廃止されているようで見当たりませんでした。dynCall()も見当たりません。ですので、dynCall()の代わりに直接dynCall_viといったパターン分用意されたメソッドを使用します。

古いバージョン(2018以前?)でのdynCall_viiの実行
// ptrCSFuncは、C#側関数のポインター
Runtime.dynCall('vii', ptrCSFunc, [arg1, arg2]);
新しいバージョン(2019以降)でのdynCall_viiの実行
Module.dynCall_vii(ptrCSFunc, strPtr1, strPtr2);

数値配列を渡す(引数)、数値配列を戻す(戻り値)

配列を引数に渡すと.jslib側ではポインターとして受け取ります。ですのでポインターから配列に戻す処理が必要です。
戻り値として配列を戻す場合は、_malloc()したポインターで戻すということをしなければなりません。
固定長配列でしたら、それほど苦労せずに受け渡すことができますが、問題は可変長配列の場合です。
特に戻り値として戻す場合は、1つのデータにしなければなりません。
配列の最初の要素に要素数を追加するという方法も考えたのですが、バイト配列だと最大でも要素数が256までになってしまいますのでこの方法はあまり有効ではありません。頑張って導き出した答えが、最初の4バイトを要素数にし以降を配列のデータとすることでとりあえずできました。
unsafeを使えばある程度すっきりしたコードになりパフォーマンスも上がりますが、ここではあえて(皆さん嫌いな)Marshalを使った方法をとってみました。

可変長配列を受け取り、可変長配列を戻すサンプルコード

// Unity

[DllImport("__Internal")]
private static extern IntPtr byteArrayFunc(byte[] arg, int length);

private static byte[] ptrToByteArray(IntPtr ptr)
{
    int len = Marshal.ReadInt32(ptr);
    byte[] arr = new byte[len];
    Marshal.Copy(IntPtr.Add(ptr, 4), arr, 0, len);
    return arr;
}

private static void test() {
    // byte[]を渡し、byte[]の戻り値を受け取る
    byte[] byteArrayArg = new byte[] { 1, 2, 3 };
    IntPtr ptrByteArray = byteArrayFunc(byteArrayArg, byteArrayArg.Length);
    byte[] byteArrayRet = ptrToByteArray(ptrByteArray);
    Debug.Log($"byteArrayFunc ret: [{string.Join(", ", byteArrayRet.Select(x => x.ToString()).ToArray())}]");
}
// .jslib

$utils: {
    arrayToReturnPtr: function (arr, type) {
        var buf = (new type(arr)).buffer;
        var ui8a = new Uint8Array(buf);
        var ptr = _malloc(ui8a.byteLength + 4);
        HEAP32.set([arr.length], ptr >> 2);
        HEAPU8.set(ui8a, ptr + 4);
        return ptr;
    },
},

byteArrayFunc: function (arg, len) {
    debugger;
    var byteArray = HEAPU8.subarray(arg, arg + len);
    console.log('byteArrayFunc arg: ' + utils.arrayToString(byteArray));

    var ret = [3, 2, 1];
    var ptr = utils.arrayToReturnPtr(ret, Uint8Array);
    return ptr;
}

_free()するタイミング

前述のサンプルコードを見ていただくと一つ問題に気付いた方もいると思います。
_malloc()したのですから_free()しなければいけません。
戻り値として_malloc()したポインターを戻す場合、いつ_free()するかという問題にあたります。
return ステートメント以降で行わないといけないですが、当然returnステートメント以降は実行されません。
簡単な方法としてはsetTimeout()を使って_free()を実行することで一応、回避可能です。

// .jslib

    //前述のサンプルコードのarrayToReturnPtr関数部分
    arrayToReturnPtr: function (arr, type) {
        var buf = (new type(arr)).buffer;
        var ui8a = new Uint8Array(buf);
        var ptr = _malloc(ui8a.byteLength + 4);
        HEAP32.set([arr.length], ptr >> 2);
        HEAPU8.set(ui8a, ptr + 4);
        // setTimeout()で_free()を行う
        setTimeout(function() { _free(ptr) }, 0);
        return ptr;
    },
//...

もっと確実な方法としては、面倒ではありますが.jslib側に_free()を行う関数を用意しておき、C#側から戻り値を受け取り用が済んだらその関数を実行することです。

// Unity

// _free()を行う関数追加
[DllImport("__Internal")]
private static extern void execFree(uint arg);

[DllImport("__Internal")]
private static extern IntPtr byteArrayFunc(byte[] arg, int length);

private static byte[] ptrToByteArray(IntPtr ptr)
{
    int len = Marshal.ReadInt32(ptr);
    byte[] arr = new byte[len];
    Marshal.Copy(IntPtr.Add(ptr, 4), arr, 0, len);
    // 用が済んだら_free()を行う
    execFree((uint)ptr);
    return arr;
}

private static void test() {
    // バイト配列を渡し、バイト配列の戻り値を受け取る
    byte[] byteArrayArg = new byte[] { 1, 2, 3 };
    IntPtr ptrByteArray = byteArrayFunc(byteArrayArg, byteArrayArg.Length);
    byte[] byteArrayRet = ptrToByteArray(ptrByteArray);
    Debug.Log($"byteArrayFunc ret: [{string.Join(", ", byteArrayRet.Select(x => x.ToString()).ToArray())}]");
}

// .jslib

// _free()を行う関数を追加
execFree(ptr) {
    _free(ptr);
}

byteArrayFunc: function (arg, len) {
    debugger;
    var byteArray = HEAPU8.subarray(arg, arg + len);
    console.log('byteArrayFunc arg: ' + utils.arrayToString(byteArray));

    var ret = [3, 2, 1];
    var ptr = utils.arrayToReturnPtr(ret, Uint8Array);
    return ptr;
}

可変長の文字列配列を渡す、文字列配列を戻す

じゃあ、可変長数値配列の受け渡しができたなら文字列配列も受け渡しできたい。文字コードはUTF8で。
数値配列の受け渡しを応用すれば一応できました。

// Unity

[DllImport("__Internal")]
private static extern void execFree(uint arg);

[DllImport("__Internal")]
private static extern IntPtr stringArrayFunc(string[] arg, int length);

private static byte[] ptrToByteArray(IntPtr ptr)
{
    Debug.Log($"ptr: {(uint)ptr}");
    int len = Marshal.ReadInt32(ptr);
    Debug.Log($"byteArry len:{len}");
    byte[] arr = new byte[len];
    Marshal.Copy(IntPtr.Add(ptr, 4), arr, 0, len);
    execFree((uint)ptr);
    return arr;
}

private static string[] ptrToStringArray(IntPtr ptr)
{
    int len = Marshal.ReadInt32(ptr);
    Debug.Log($"stringArry len:{len}");
    IntPtr[] ptrArr = new IntPtr[len];
    Debug.Log(ptrArr);
    Marshal.Copy(IntPtr.Add(ptr, 4), ptrArr, 0, len);
    List<string> ret = new List<string>();
    for (var i = 0; i < len; i++)
    {
        var byteArray = ptrToByteArray(ptrArr[i]);
        var str = Encoding.UTF8.GetString(byteArray);
        ret.Add(str);
    }
    execFree((uint)ptr);
    return ret.ToArray();
}

public static void test()
{
    string[] stringArrayArg = new string[] { "foo", "bar", "baz" };
    IntPtr ptrStringArray = stringArrayFunc(stringArrayArg, stringArrayArg.Length);
    string[] stringArrayRet = ptrToStringArray(ptrStringArray);
    Debug.Log($"stringArrayFunc ret: [{string.Join(", ", stringArrayRet)}]");
}

// .jslib

stringArrayFunc: function (arg, len) {
    var strArray = [];
    for (var i = 0; i < len; i++) {
        var ptr = HEAP32[(arg >> 2) + i];
        var str = Pointer_stringify(ptr);
        strArray.push(str);
    }
    console.log('strArrayFunc arg: ' + strArray);

    var ret = ['hoge', 'fuga', 'piyo', 'hogera', 'ほげほげ', '叱る'];
    var retPtr = utils.stringArrayToReturnPtr(ret);
    return retPtr;
}

見ていただくとわかる通り、可変長数値配列の受け渡しもそうですが、可変長文字列配列の受け渡しはさらにめんどいことに。はっきり言ってJSONで受け渡したほうが楽です。
文字列を_malloc()した場合は、Unity側で自動で_free()してくれるのですが、C#側でUTF8に変換したいためにbyte[]に変換しているため自動で_free()されません。
(Marshal.PtrToStringAnsi()で一応、ポインターから文字列に変換することは可能ですがUTF16に変換されてしまいます。.NET5ではMarshal.PtrToStringUTF8()というまんまな関数が用意されましたが、いかんせんUnityでの.NET5のサポートはまだまだ先になるようです)

固定長数値配列の参照渡し

UnityのWebXR Exporterというアセットのソースを覗いてたら、お!っと思うコードが記述されていました。

// Unity

[DllImport("__Internal")]
private static extern void refIntArrayFunc(float[] a, int l);

int[] refIntArray = new int[3];
refIntArrayFunc(refIntArray, a.Length);
// .jslib

refIntArrayFunc: function (arg, len) {
    Module.refIntArray = new Int32Array(buffer, arg, len);
}

このように書くことで、C#側のrefIntArrayと.jslib側のModule.refIntArrayは参照渡しの関係となり、.jslib側でModule.refIntArrayの値を変更すると、(returnステートメントなしに)C#のrefIntArrayに値が反映されます。

テクスチャー

テクスチャーは、C#側で生成し、Texture.GetNativeTexturePtr()でポインターを取得し、ポインターを.jslibの関数に渡す。.jslib側でGL.textures[ptr]でテクスチャーを参照することが可能"らしいです"
"らしいです"というのは、C#で

var texture = new Texture2D(0, 0, TextureFormat.ARGB32, false);
var ptr = texture.GetNativeTexturePtr();

としても、ptrは0になり有効な値になってくれません。
"もし、有効なポインターを取得する方法をご存じの方がいらっしゃればぜひご教授をお願いします"

仮に有効なポインターの値が取得できた場合は

// .jslib

textureFunc(ptr) {
    GLctx.bindTexture(GLctx.TEXTURE_2D, GL.textures[ptr]);
    GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, true);
    GLctx.texImage2D(GLctx.TEXTURE_2D, 0, GLctx.RGBA, GLctx.RGBA,GLctx.UNSIGNED_BYTE, video);
    GLctx.pixelStorei(GLctx.UNPACK_FLIP_Y_WEBGL, false);
}

といったコードを書くことにより、そのテクスチャーにimgエレメントの画像や、videoエレメントの映像、WebRTCなどのMediaStreamの映像などもほぼ直接的に表示できるようになる"はずです"

最後に

Unity (#1) Advent Calendar 第1日目の内容は以上となります。
ちょっとネタに走った感はありますが、.jslibを書けるようになればUnityだけではできないこと、特にJS(Web)のいろんなAPIなどをUnityに取り入れることが可能となりますのでぜひかけるようになりましょう!

あ、あとまとめたテストコードも載せておきます

// Unity

using AOT;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using UnityEngine;

public class jslibtest : MonoBehaviour
{
    [DllImport("__Internal")]
    private static extern void execFree(uint arg);


    [DllImport("__Internal")]
    private static extern byte byteFunc(byte arg);

    [DllImport("__Internal")]
    private static extern short shortFunc(short arg);

    [DllImport("__Internal")]
    private static extern int intFunc(int arg);

    [DllImport("__Internal")]
    private static extern float floatFunc(float arg);

    [DllImport("__Internal")]
    private static extern double doubleFunc(double arg);

    [DllImport("__Internal")]
    private static extern IntPtr byteArrayFunc(byte[] arg, int length);

    [DllImport("__Internal")]
    private static extern IntPtr shortArrayFunc(short[] arg, int length);

    [DllImport("__Internal")]
    private static extern IntPtr intArrayFunc(int[] arg, int length);

    [DllImport("__Internal")]
    private static extern IntPtr floatArrayFunc(float[] arg, int length);


    [DllImport("__Internal")]
    private static extern IntPtr doubleArrayFunc(double[] arg, int length);

    [DllImport("__Internal")]
    private static extern IntPtr stringArrayFunc(string[] arg, int length);

    [DllImport("__Internal")]
    private static extern void refIntArrayFunc(int[] arr, int len);

    private int[] refIntArray = new int[3];

    private void Start()
    {
        test();

        refIntArrayFunc(refIntArray, refIntArray.Length);
        StartCoroutine(chekRefArray());
    }

    IEnumerator chekRefArray ()
    {
        while(true)
        {
            yield return new WaitForSeconds(0.3f);
            Debug.Log($"refIntArray: [{string.Join(", ", refIntArray.Select(x => $"{x}"))}]");
        }
    }

    private void Update()
    {
    }

    private static byte[] ptrToByteArray(IntPtr ptr)
    {
        Debug.Log($"ptr: {(uint)ptr}");
        int len = Marshal.ReadInt32(ptr);
        Debug.Log($"byteArry len:{len}");
        byte[] arr = new byte[len];
        Marshal.Copy(IntPtr.Add(ptr, 4), arr, 0, len);
        execFree((uint)ptr);
        return arr;
    }

    private static short[] ptrToShortArray(IntPtr ptr)
    {
        int len = Marshal.ReadInt32(ptr);
        Debug.Log($"shortArry len:{len}");
        short[] arr = new short[len];
        Marshal.Copy(IntPtr.Add(ptr, 4), arr, 0, len);
        return arr;
    }

    private static int[] ptrToIntArray(IntPtr ptr)
    {
        int len = Marshal.ReadInt32(ptr);
        Debug.Log($"intArry len:{len}");
        int[] arr = new int[len];
        Marshal.Copy(IntPtr.Add(ptr, 4), arr, 0, len);
        return arr;
    }

    private static float[] ptrToFloatArray(IntPtr ptr)
    {
        int len = Marshal.ReadInt32(ptr);
        Debug.Log($"floatArry len:{len}");
        float[] arr = new float[len];
        Marshal.Copy(IntPtr.Add(ptr, 4), arr, 0, len);
        return arr;
    }

    private static double[] ptrToDoubleArray(IntPtr ptr)
    {
        int len = Marshal.ReadInt32(ptr);
        Debug.Log($"doubleArry len:{len}");
        double[] arr = new double[len];
        Marshal.Copy(IntPtr.Add(ptr, 4), arr, 0, len);
        return arr;
    }

    private static string[] ptrToStringArray(IntPtr ptr)
    {
        int len = Marshal.ReadInt32(ptr);
        Debug.Log($"stringArry len:{len}");
        IntPtr[] ptrArr = new IntPtr[len];
        Debug.Log(ptrArr);
        Marshal.Copy(IntPtr.Add(ptr, 4), ptrArr, 0, len);
        List<string> ret = new List<string>();
        for (var i = 0; i < len; i++)
        {
            var byteArray = ptrToByteArray(ptrArr[i]);
            var str = Encoding.UTF8.GetString(byteArray);
            ret.Add(str);
        }
        execFree((uint)ptr);
        return ret.ToArray();
    }

    public static void test()
    {
        byte byteArg = 210;
        byte byteRet = byteFunc(byteArg);
        Debug.Log($"byteFunc ret: {byteRet}");

        short shortArg = 210;
        short shortRet = shortFunc(shortArg);
        Debug.Log($"shortFunc ret: {shortRet}");

        int intArg = 210;
        int intRet = intFunc(intArg);
        Debug.Log($"intFunc ret: {intRet}");

        float floatArg = 210.123f;
        float floatRet = floatFunc(floatArg);
        Debug.Log($"floatFunc ret: {floatRet}");

        double doubleArg = 210.321d;
        double doubleRet = doubleFunc(doubleArg);
        Debug.Log($"doubleFunc ret: {doubleRet}");


        byte[] byteArrayArg = new byte[] { 1, 2, 3 };
        IntPtr ptrByteArray = byteArrayFunc(byteArrayArg, byteArrayArg.Length);
        byte[] byteArrayRet = ptrToByteArray(ptrByteArray);
        Debug.Log($"byteArrayFunc ret: [{string.Join(", ", byteArrayRet.Select(x => $"{x}"))}]");

        short[] shortArrayArg = new short[] { 4, 5, 6 };
        IntPtr ptrShortArray = shortArrayFunc(shortArrayArg, shortArrayArg.Length);
        short[] shortArrayRet = ptrToShortArray(ptrShortArray);
        Debug.Log($"shortArrayFunc ret: [{string.Join(", ", shortArrayRet.Select(x => $"{x}"))}]");

        int[] intArrayArg = new int[] { 7, 8, 9 };
        IntPtr ptrIntArray = intArrayFunc(intArrayArg, intArrayArg.Length);
        int[] intArrayRet = ptrToIntArray(ptrIntArray);
        Debug.Log($"intArrayFunc ret: [{string.Join(", ", intArrayRet.Select(x => $"{x}"))}]");

        float[] floatArrayArg = new float[] { 1.1f, 2.2f, 3.3f };
        IntPtr ptrFloatArray = floatArrayFunc(floatArrayArg, floatArrayArg.Length);
        float[] floatArrayRet = ptrToFloatArray(ptrFloatArray);
        Debug.Log($"floatArrayFunc ret: [{string.Join(", ", floatArrayRet.Select(x => $"{x}"))}]");

        double[] doubleArrayArg = new double[] { 5.5d, 6.6d, 7.7d };
        IntPtr ptrDoubleArray = doubleArrayFunc(doubleArrayArg, doubleArrayArg.Length);
        double[] doubleArrayRet = ptrToDoubleArray(ptrDoubleArray);
        Debug.Log($"doubleArrayFunc ret: [{string.Join(", ", doubleArrayRet.Select(x => $"{x}"))}]");

        string[] stringArrayArg = new string[] { "foo", "bar", "baz" };
        IntPtr ptrStringArray = stringArrayFunc(stringArrayArg, stringArrayArg.Length);
        string[] stringArrayRet = ptrToStringArray(ptrStringArray);
        Debug.Log($"stringArrayFunc ret: [{string.Join(", ", stringArrayRet)}]");
    }
}
var lib = {
    $utils: {
        arrayToString: function (arr) {
            var ret = '[';
            for (var i = 0; i < arr.length; i++) {
                var spl = i === arr.length - 1 ? '' : ', ';
                ret += arr[i].toString() + spl;
            }
            return ret + ']';
        },
        arrayToReturnPtr: function (arr, type) {
            var buf = (new type(arr)).buffer;
            var ui8a = new Uint8Array(buf);
            var ptr = _malloc(ui8a.byteLength + 4);
            HEAP32.set([arr.length], ptr >> 2);
            HEAPU8.set(ui8a, ptr + 4);
            // setTimeout(function() { _free(ptr) }, 0);
            return ptr;
        },
        stringArrayToReturnPtr: function (strArr) {
            var ptrArray = [];
            var enc = new TextEncoder();
            for (var i = 0; i < strArr.length; i++) {
                var byteArray = enc.encode(strArr[i]);
                var ptr = utils.arrayToReturnPtr(byteArray, Uint8Array);
                ptrArray.push(ptr);
            }
            var ptr = utils.arrayToReturnPtr(ptrArray, Uint32Array);
            return ptr;
        }
    },

    execFree: function (ptr) {
        console.log('free ptr: ' + ptr);
        _free(ptr);
    },

    byteFunc: function (arg) {
        console.log('byteFunc arg: ' + arg);

        var ret = 128;
        return ret;
    },

    shortFunc: function (arg) {
        console.log('shortFunc arg: ' + arg);

        var ret = 128;
        return ret;
    },

    intFunc: function (arg) {
        console.log('intFunc arg: ' + arg);

        var ret = 128;
        return ret;
    },

    longFunc: function (arg) {
        console.log('longFunc arg: ' + arg);
        var ret = 128;
        return ret;
    },

    floatFunc: function (arg) {
        console.log('floatFunc arg: ' + arg);

        var ret = 128.123;
        return ret;
    },

    doubleFunc: function (arg) {
        console.log('doubleFunc arg: ' + arg);

        var ret = 128.123;
        return ret;
    },

    byteArrayFunc: function (arg, len) {
        var byteArray = HEAPU8.subarray(arg, arg + len);
        console.log('byteArrayFunc arg: ' + utils.arrayToString(byteArray));

        var ret = [3, 2, 1];
        var ptr = utils.arrayToReturnPtr(ret, Uint8Array);
        console.log('jslib ptr: ' + ptr);
        return ptr;
    },

    shortArrayFunc: function (arg, len) {
        var shortArray = HEAP16.subarray(arg, len);
        console.log('shortArrayFunc arg: ' + shortArray);

        var ret = [6, 5, 4];
        var ptr = utils.arrayToReturnPtr(ret, Int16Array);
        return ptr;
    },

    intArrayFunc: function (arg, len) {
        var intArray = HEAP32.subarray(arg, len);
        console.log('intArrayFunc arg: ' + intArray);

        var ret = [9, 8, 7];
        var ptr = utils.arrayToReturnPtr(ret, Int32Array);
        return ptr;
    },

    floatArrayFunc: function (arg, len) {
        var floatArray = HEAPF32.subarray(arg, len);
        console.log('floatFunc arg: ' + floatArray);

        var ret = [3.3, 2.2, 1.1];
        var ptr = utils.arrayToReturnPtr(ret, Float32Array);
        return ptr;
    },

    doubleArrayFunc: function (arg, len) {
        var doubleArray = HEAPF64.subarray(arg, len);
        console.log('doubleFunc arg: ' + doubleArray);

        var ret = [6.6, 5.5, 4.4, 3.3, 2.2];
        var ptr = utils.arrayToReturnPtr(ret, Float64Array);
        return ptr;
    },

    stringArrayFunc: function (arg, len) {
        var strArray = [];
        for (var i = 0; i < len; i++) {
            var ptr = HEAP32[(arg >> 2) + i];
            var str = Pointer_stringify(ptr);
            strArray.push(str);
        }
        console.log('strArrayFunc arg: ' + strArray);

        var ret = ['hoge', 'fuga', 'piyo', 'hogera', 'ほげほげ', '叱る'];
        var retPtr = utils.stringArrayToReturnPtr(ret);
        return retPtr;
    },

    refIntArrayFunc: function (arg, len) {
        console.log('ref len:' + len);
        Module.refIntArray = new Int32Array(buffer, arg, len);
        Module.sampleValue = 0;
        setInterval(function () {
            console.log('refIntArray update: ' + Module.refIntArray.length + ' ' + Module.sampleValue );
            for (var i = 0; i < Module.refIntArray.length; i++) {
                Module.refIntArray[i] = Module.sampleValue + i;
            }
            Module.sampleValue += Module.refIntArray.length;
        }, 1000);
    }
};
autoAddDeps(lib, '$utils');
mergeInto(LibraryManager.library, lib);
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

unity初心者が初めてゲームを作って思ったこと

はじめまして、N高等学校一年のdaichiと申します。
プログラミング初心者の私が、学習の記録を残そうと思って書いてます。
良ければアドバイスなど頂けると幸いです。

今月やったこと

unityにほぼ初めて触りました!(前に触ったことはあったけどしっかりやってなかったです)
N予備校のunity講座を進め初めて学習しました!

スクリーンショット 2020-11-30 21.42.18.png
このような3DアクションゲームをN予備校に従いながら作りました!

改善点

C#を勉強出来ていないので何やってるのかわからなかった…
paizaなどでC#を勉強するなどする。
勉強すれば、基礎として何を書いているかわかるはず…

あとがき

めちゃくちゃ初心者なので、こういう学習方法あるよとか、アドバイスいただけるとありがたいです…

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

空から大量にコインを降らせようとしたら爆散した話

こちらは Unity #2 Advent Calendar 2020 の 1日目の記事です ?

背景

kintone hack 2019Cybozu Days 2020で、kintoneのアプリのデータをUnity内で可視化するプロジェクトを披露してきました。

このプロジェクトの中でも自分が気に入ってるのは、kintone内の案件管理データベースから直近の売上金額を集計し、売上の分だけプレイヤーの目の前にコインを大量に降らせるという、ファンキーでリッチなデータ可視化方法でした。

kintonehackfallingcoins.gif

金だー!金だー!

実はこの大量コイン生成を実現するのに何回か失敗したので、この記事ではコインの降らし方について案内をします。なお、kintoneからデータを取得する方法については別記事で書き上げているので、この記事では割愛します。

コインを生成してみた

コインをUnityの環境内に生成するのは簡単です。生成したいオブジェクトをprehabとして準備し、Instantiateすれば生成されます。Instantiateする際の角度はランダムになるように調整もできます。

Vector3 CoinPosition = new Vector3(0,3,0);
float CoinRotationX = Random.Range(0.0f,360.0f);
float CoinRotationY = Random.Range(0.0f,360.0f);
float CoinRotationZ = Random.Range(0.0f,360.0f);
Instantiate(CoinPrefab, CoinPosition, Quaternion.Euler(CoinRotationX,CoinRotationY,CoinRotationZ));

onecoinfall.gif

コツン⌒☆*

簡単ですね。では、これを大量に降らせるようにコードをいじってみましょう。

コインを複数生成してみた

コインを複数生成するので、単純にループ処理でInstantiateすれば良いのかなっと思ってforループで実装してみました。これでコインが連続でチャリンチャリーンっと滝のように流れてくるはずです。

int NumberOfCoins = 100;

for(int i=0; i<NumberOfCoins; i++)
{
    Vector3 CoinPosition = new Vector3(0,3,0);
    float CoinRotationX = Random.Range(0.0f,360.0f);
    float CoinRotationY = Random.Range(0.0f,360.0f);
    float CoinRotationZ = Random.Range(0.0f,360.0f);
    Instantiate(CoinPrefab, CoinPosition, Quaternion.Euler(CoinRotationX,CoinRotationY,CoinRotationZ));
}

100coinfall.gif

( ゚д゚)

(つд⊂)ゴシゴシ

(;゚д゚)

(つд⊂)ゴシゴシ
  
(;゚ Д゚) …?! what?

爆 ☆ 散

爆散しました・・・何が起きたんでしょうか・・・おそらく同じ場所にBox Colliderがついているオブジェクトが一瞬に生成されたことで、オブジェクト同士が反発してしまい、爆散してしまったんだと思います。最初からクライマックスですね。

コインの生成位置をずらしてみた

ほぼ同じタイミングで特定の位置にコインが生成されるとコイン同士が反発しあって爆散するので、コインの生成位置を1つ1つずらしながら生成すれば良いのでは?という考えで実装をさらに進めてみましょう。位置をずらすけれど、だいたい同じエリアに生成させたい・・・となると螺旋状に降らせるのが良いかもしれません。

簡単な実装方法として、X軸とY軸をAdjuster変数で少しずつずらして、一周したらY軸を少しあげるようにしてみました。キレイな螺旋ではありませんが、爆散を防ぐには十分だと思います。

int NumberOfCoins = 100;

Vector3 AdjusterA = new Vector3(0.25f, 0, 0);
Vector3 AdjusterB = new Vector3(0, 0, 0.25f);
Vector3 AdjusterC = new Vector3(-0.25f, 0, 0);
Vector3 AdjusterD = new Vector3(0, 0.5f, -0.25f);

Vector3 CoinPosition = new Vector3(0,3,0);

for(int i=0; i<NumberOfCoins; i++)
{
    switch (i % 4)
    {
        case 0:
            CoinPosition = CoinPosition + AdjusterA;
            break;
        case 1:
            CoinPosition = CoinPosition + AdjusterB;
            break;
        case 2:
            CoinPosition = CoinPosition + AdjusterC;
            break;
        case 3:
            CoinPosition = CoinPosition + AdjusterD;
            break;
    }
    float CoinRotationX = Random.Range(0.0f,360.0f);
    float CoinRotationY = Random.Range(0.0f,360.0f);
    float CoinRotationZ = Random.Range(0.0f,360.0f);
    Instantiate(CoinPrefab, CoinPosition, Quaternion.Euler(CoinRotationX,CoinRotationY,CoinRotationZ));
}

100coinrotation.gif

ドバドバドバドバドバー⌒☆*

コインが爆散してませんし、これは中々良い感じにコインを降らせたのではないでしょうか。

コインの生成タイミングを遅らせてみた

実は先程のコインを降らせる方法に欠点がありました。

カメラが小さな範囲しか捉えてないのであればこの方法でも良いんですが、生成するコインが増えれば増えるほどコインがタワーのように高くなってしまいます。

少しズームアウトして全体を見てみましょう:

30コインの場合↓
30coins4secs.gif

100コインの場合↓
100coins4secs.gif

300コインの場合↓
300coins4secs.gif

んー、これは見た目がかなり悪いですね。

4コインごとにY軸の値を増やしてるので、コインが増えたら当然その分コインのタワーは高くなります。

Y軸という「位置」をずらすことにより爆散を防いでいたのですが、こんどは生成する「時間」をずらしてみましょう。UnityではWaitForSeconds() 関数を使用すれば、コインとコインが生成される間にディレイを設定することができ、例え同じ位置に生成されても先に落ちたコインと次に落ちるコインがぶつからないように調整することが可能です。なお、WaitForSeconds() 関数はcoroutineの中でしか使用が出来ないので、下記のように関数化して修正する必要があります。Y軸の修正も忘れずにしておきましょう。

void Start()
{
    StartCoroutine("InstantiateCoinsWithDelay");
}

private IEnumerator InstantiateCoinsWithDelay()
{
    int NumberOfCoins = 100;

    Vector3 AdjusterA = new Vector3(0.25f, 0, 0);
    Vector3 AdjusterB = new Vector3(0, 0, 0.25f);
    Vector3 AdjusterC = new Vector3(-0.25f, 0, 0);
    Vector3 AdjusterD = new Vector3(0, 0, -0.25f);

    Vector3 CoinPosition = new Vector3(0,3.5f,0);

    for(int i=0; i<NumberOfCoins; i++)
    {
        switch (i % 4)
        {
            case 0:
                CoinPosition = CoinPosition + AdjusterA;
                break;
            case 1:
                CoinPosition = CoinPosition + AdjusterB;
                break;
            case 2:
                CoinPosition = CoinPosition + AdjusterC;
                break;
            case 3:
                CoinPosition = CoinPosition + AdjusterD;
                break;
        }

        float CoinRotationX = Random.Range(0.0f,360.0f);
        float CoinRotationY = Random.Range(0.0f,360.0f);
        float CoinRotationZ = Random.Range(0.0f,360.0f);
        Instantiate(CoinPrefab, CoinPosition, Quaternion.Euler(CoinRotationX,CoinRotationY,CoinRotationZ));
        yield return new WaitForSeconds(0.1f);
    }
}

coinswithdelay.gif

ぽこぽこぽこぽこー⌒☆*

良い感じですね。円を描きながら(いや、実際は四点を順番に回ってるだけなので四角か)、コインとコインの生成の間にディレイを入れているので、爆散せずに済んでいます。

コインをさらに自然に生成してみた

いや、自然にコインを生成するってなんやねん ( ´Д`)っ))Д゚)・∵.

っと思いながら書いてますが、ようは雨が降るようなイメージでコインを降らしてみたいんですよね。

なのでコインの人工的な螺旋状の降り方を一旦やめて、自然界での天気のように「特定のエリア内に」かつ「ランダムな場所に」かつ「ランダムな間隔で」コインを降らせようと思います。

void Start()
{
    StartCoroutine("InstantiateRandomCoins");
}

private IEnumerator InstantiateRandomCoins()
{
    int NumberOfCoins = 300;
    Vector3 CoinPosition = new Vector3(0,0,0);

    for(int i=0; i<NumberOfCoins; i++)
    {
        CoinPosition = new Vector3(Random.Range(0,1.6f),Random.Range(4.0f,4.8f),Random.Range(0,1.6f));
        float CoinRotationX = Random.Range(0.0f,360.0f);
        float CoinRotationY = Random.Range(0.0f,360.0f);
        float CoinRotationZ = Random.Range(0.0f,360.0f);
        Instantiate(CoinPrefab, CoinPosition, Quaternion.Euler(CoinRotationX,CoinRotationY,CoinRotationZ));
        yield return new WaitForSeconds(Random.Range(0.01f,0.07f));
    }
}

coinsfromkintonecloud.gif

雨っぽく見えるように、ついでに雲のオブジェクトも配置してみました。どうでしょうか、少しは自然界の雨のように見えますでしょうか。人工的なコインを大量に降らせてるから雨もくそもないんですけどね。アハハ。自分でも何作ってんのか良くわからんです。

「雨なんて降って・・・」

「いや」「雨だよ」

参考

今回の記事に使用したアセットや参考資料です

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

Corgi Engineの使用方法メモ【Unity】

この記事について

Unityアセットストアで買ったCorgi Engineをなんとか使ってみようとする覚え書きです。
超初心者が超初心者にあてた、あまり参考にならないアレ。

もくじ

ようそつなので基礎から書く。

  1. 大まかな流れ
  2. シーンの準備
    1. そのまま放り込めるもの
    2. ちょっと準備が必要なもの1 LevelManager
    3. ちょっと準備が必要なもの2 レベル
    4. 調整とか
    5. Tips カメラがどうなっているかの確認方法
    6. ひとまずこうなる
  3. プレイヤーのコーギー君作成
    1. コーギー君のもとを作る
    2. コーギー君を人体錬成する
    3. 見た目を追加する
    4. 調整
    5. プレハブ化とスポーン設定
  4. テストプレイ
  5. 総括と今後

大まかな流れ

基本的な流れは以下の通り。

  • まずコーギーエンジンを使えるように「最小限のシーン」を用意する
  • プレイヤーキャラであるコーギー君を作成する
  • 細かな調整を行う

シーンの準備

まず空のシーンを作って、それを「最小限のシーン要件」とやらにしてみる。

そのまま放り込めるもの

必要なもの1.png
下の表にあるプレハブアセットを空っぽのシーンに放り込む。

必要なファイル 場所
GameManagers Assets> CorgiEngine> Common> Prefabs> LevelManagers
UICamera Assets> CorgiEngine> Common> Prefabs> GUI
RegularCamera Assets> CorgiEngine> Common> Prefabs> Camera

ちょっと準備が必要なもの1 LevelManager

必要なもの2.png
シーンに空のオブジェクトを作って、そこにコンポーネントとしてLevelManagerをくっつける。
LevelManagerはスクリプトとしてアセット内に用意されてて、Assets> CorgiEngine> Common> Scripts> Managersに入ってる。

ちょっと準備が必要なもの2 レベル

必要なもの3.png
キャラクターが立てる地面を用意する。
まず空のオブジェクトを作り、その中へDemoにある丁度よさげな足場をドラッグ&ドロップ。

調整とか

  1. 初めから入ってるMainCameraを消す。代わりにさっき入れたRegularCameraを使うから古いのはいらないのだ。
  2. RegularCameraの位置を0.0.-5くらいに直す。カメラさんが明後日の場所を映していては意味ないのだ。
  3. LevelのZ位置を修正する。カメラに足場が映らないとかよくある。

Tips カメラがどうなっているかの確認方法

必要なもの4.png

ひとまずこうなる

1章完成系A.png
1章完成系B.png

プレイヤーのコーギー君作成

コーギー君のもとを作る

コーギー君のもと.png
空のオブジェクトを作り、そこにコンポーネントとしてCharacterをくっつける。
CharacterはAssets> CorgiEngine> Common> Scripts> Agents> Coreに入ってる。

コーギー君を人体錬成する

人体錬成.png
Characterの一番下にあるAutoBuild Player Characterを押すと自動でプレイヤーが出来上がる。
すごい!!!
出来上がりは下の通り。
人体錬成できあがり.png

見た目を追加する

このままだと透明人間なので、コーギー君用スプライトを追加する。
いびつなコーギー君.png
おお、なんとかわいいコーギー君なんでしょう!
プロジェクトの中にSpritesというフォルダを作成し、そこにコーギー君の画像を格納します。
 ※この画像は練習等ご自由にお使いください。

コーギー君スプライトを選択.png
さっき人体錬成した透明コーギー君へコンポーネント「スプライトレンダラー」を追加し、スプライトに画像を指定します。

調整

おお、なんという大きさだコーギー君!
おっきなコーギー君.png
調整項目は以下の通り

  • 拡大縮小をx0.3,y0.3とする
  • BoxCollider2Dのサイズをx6.26、y4.34とする
  • BoxCollider2Dのオフセットをx0、y-0.53とする

コーギー君調整.png

プレハブ化とスポーン設定

コーギー君スポーン設定.png
出来上がったコーギー君をプロジェクトビューへドラック&ドロップ。プレハブ化する。
不要になったシーン内のコーギー君は削除しておく。
そののちLevelManagerのPlayerPrefabsへコーギー君プレハブを設置する。

テストプレイ

動くコーギー君.gif
なんということでしょう!
透明な四角い箱に囲まれたコーギー君が元気に2段ジャンプするではありませんか!
ピクリとも身体を動かさず動き回る様は不気味というほかない。

総括と今後

できれば次はコーギー君にアニメーションを追加したり、透明な四角い箱を研究したりしたい。

まったくの初心者ですので、「もっとこうした方がいい」というアドバイス等ありましたら是非ともお教えいただければ幸いです。
……特に、プレイヤーキャラの拡大縮小をいじったのは今後に響かないかが心配です。

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

【Unity】C# Job System + Burstで波動方程式を実装し、 ShaderGraphで水を描画する

はじめに

波を作ってみたので紹介します。
YouTube

output.gif

C# Job Systemで波動方程式を実装し、ShaderGraphで水を描画しています。

GitHub
https://github.com/rngtm/Unity-JobSystem-WaveEquation

§1 . 波動方程式

2次元の波の運動は以下の数式で表されます。

\frac{\partial^2 u }{\partial t^2} = s^2 \left( \frac{\partial^2 u }{\partial x^2} + \frac{\partial^2 u }{\partial y^2} \right)

$u = u(x,y,t)$ は水面の波の変位、$s$は波の伝わる速さを表しています。

§2 . 関数f(x, y)の2階微分の計算

ここで、関数xとyの関数 $f(x,y)$ の2階微分は、ある小さな値 $h$ を使って以下の式で計算することができます。

$$
\frac{\partial^2}{\partial x^2}f(x, y) = \frac{f(x + h, y) + f(x - h,y) - 2 f(x,y)}{h^2} + O(h^2)
$$

$$
\frac{\partial^2}{\partial y^2}f(x, y) = \frac{f(x, y + h) + f(x,y - h) - 2 f(x,y)}{h^2} + O(h^2)
$$

$O(h^2)$は誤差を表しており、$h$を$0$に近づけるほど、誤差$O(h^2)$は0に近づきます。

※上記の式はテイラー展開を利用することで導出できますが、ここでは説明しません。
参考 : https://na.cs.tsukuba.ac.jp/jikken/wp-content/uploads/2016/07/wave.pdf

§3 . 波の加速度の計算

ここでは、時刻tにおける波の変位を $u(x,y)$ と表します。

先ほどの §2. の計算式の $ f(x,y) \rightarrow u(x,y)$ と置き換えると、$u$の$x, y$に関する2階微分を得ることができます。
$h$は$\Delta x, \Delta y$に置き換えます。

$$
\frac{\partial^2}{\partial x^2}u(x, y) \approx \frac{u(x + \Delta x, y) + f(x - \Delta x,y) - 2 u(x,y)}{(\Delta x)^2}
$$

$$
\frac{\partial^2}{\partial y^2}u(x, y) \approx \frac{u(x, y + \Delta y) + f(x,y - \Delta y) - 2 u(x,y)}{(\Delta y)^2}
$$

2階微分の足し合わせに波の伝わる速さ $s^2$を乗算すると、波の加速度 $ \frac{\partial^2 u }{\partial t^2} $ が求まります。

$$
\frac{\partial^2 u }{\partial t^2} = s^2 \left( \frac{\partial^2 u}{\partial x^2} + \frac{\partial^2 u }{\partial y^2} \right)
$$

§4. MonoBehaviourで波動方程式を実装してみる

まずはC# Job Systemを使わず、通常のMonoBehaviourで波動方程式シミュレーションを実装してみました。

ソースコード全体(WaveMesh2D.cs)
WaveMesh2D.cs
using System.Linq;
using UnityEngine;

/// <summary>
/// 2次元の波動方程式の実装
/// </summary>
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
public class WaveMesh2D : MonoBehaviour
{
    [SerializeField] private Vector2Int resolution = new Vector2Int(16, 16); // メッシュ解像度
    [SerializeField] private float s = 0.5f; // 波の伝わる速さ
    [SerializeField] private Vector2 meshSize = new Vector2(8f, 8f);
    private Mesh mesh;
    private Vector3[] vertices = null;
    private Vector3[] normals = null;
    private float[,] waveTable; // 波の変位
    private float[,] waveSpeedTable;

    [SerializeField] private float rainForceMin = 0.1f;
    [SerializeField] private float rainForceMax = 0.3f;
    [SerializeField] private int rainIntervalFrame = 10;

    // Start is called before the first frame update
    void Start()
    {
        // 波のデータ作成
        waveTable = new float[resolution.x, resolution.y];
        waveSpeedTable = new float[resolution.x, resolution.y];

        // 波の初期化
        InitializeWave();

        // メッシュの作成
        CreateMesh();
    }

    /// <summary>
    /// 波の初期状態の設定
    /// </summary>
    private void InitializeWave()
    {
        // 座標(1,1)を中心にして、水を持ち上げます
        Vector2 center = new Vector2(1f, 1f);
        for (int yi = 1; yi < resolution.y - 1; yi++) // 端点(yi = 0, yi = resolution.y - 1 ) は固定する
        {
            for (int xi = 1; xi < resolution.x - 1; xi++) // 端点(xi = 0, xi = resolution.x - 1 ) は固定する
            {
                var p = GetVertexPosition(xi, yi);
                float r = (new Vector2(p.x, p.z) - center).magnitude;
                float h = Mathf.Exp(-r * 8.0f) * 2.0f;
                h = Mathf.Clamp01(h);
                waveTable[xi, yi] = h;
            }
        }
    }

    void FixedUpdate()
    {
        SolveWaveEquation(Time.fixedDeltaTime);
    }

    // 2次元波動方程式の実装
    void SolveWaveEquation(float deltaTime)
    {
        float dx = meshSize.x / resolution.x;
        float dy = meshSize.y / resolution.y;

        // 位置を元に加速度 (d/dt)^2 u の計算
        for (int yi = 1; yi < resolution.y - 1; yi++)
        {
            for (int xi = 1; xi < resolution.x - 1; xi++)
            {
                float wave = waveTable[xi, yi];
                float waveX1 = waveTable[xi - 1, yi];
                float waveX2 = waveTable[xi + 1, yi];
                float waveY1 = waveTable[xi, yi - 1];
                float waveY2 = waveTable[xi, yi + 1];

                // (d/dx)^2 u     
                float dudx2 = (waveX1 + waveX2 - 2f * wave) / dx / dx;

                // (d/dy)^2 u     
                float dudz2 = (waveY1 + waveY2 - 2f * waveTable[xi, yi]) / dy / dy;

                float waveAccel = s * s * (dudx2 + dudz2);
                waveSpeedTable[xi, yi] += waveAccel * deltaTime;
            }
        }

        // 速度の反映
        for (int yi = 1; yi < resolution.y - 1; yi++)
        {
            for (int xi = 1; xi < resolution.x - 1; xi++)
            {
                waveTable[xi, yi] += waveSpeedTable[xi, yi] * deltaTime;
            }
        }

        UpdateMesh();
    }

    /// <summary>
    /// メッシュ作成
    /// </summary>
    void CreateMesh()
    {
        mesh = new Mesh();

        // 頂点の作成
        int vertexCount = resolution.x * resolution.y;

        // 頂点・法線・UV作成
        vertices = new Vector3[vertexCount];
        normals = new Vector3[vertexCount].Select(x => new Vector3(0, 1, 0)).ToArray();

        var uv = new Vector2[vertexCount];
        int vi = 0;
        for (int yi = 0; yi < resolution.y; yi++)
        {
            for (int xi = 0; xi < resolution.x; xi++)
            {
                vertices[vi] = GetVertexPosition(xi, yi);
                uv[vi] = new Vector2((float)xi / (resolution.x - 1), (float)yi / (resolution.y - 1));
                vi++;
            }
        }

        // 頂点インデックス作成
        int triangleCount = (resolution.x - 1) * (resolution.y - 1) * 6;
        int[] triangles = new int[triangleCount];
        int offset = 0;
        int ti = 0;
        for (int yi = 0; yi < resolution.y - 1; yi++)
        {
            for (int xi = 0; xi < resolution.x - 1; xi++)
            {
                triangles[ti++] = offset;
                triangles[ti++] = offset + resolution.x;
                triangles[ti++] = offset + 1;
                triangles[ti++] = offset + resolution.x;
                triangles[ti++] = offset + resolution.x + 1;
                triangles[ti++] = offset + 1;

                offset += 1;
            }

            offset += 1;
        }

        mesh.SetVertices(vertices);
        mesh.uv = uv;
        mesh.SetTriangles(triangles, 0);

        GetComponent<MeshFilter>().mesh = mesh;
    }

    /// <summary>
    /// メッシュ更新
    /// </summary>
    private void UpdateMesh()
    {
        float dx = meshSize.x / resolution.x;
        float dy = meshSize.y / resolution.y;
        int vi = 0;
        for (int yi = 0; yi < resolution.y; yi++)
        {
            for (int xi = 0; xi < resolution.x; xi++)
            {
                vertices[vi] = GetVertexPosition(xi, yi);
                vi++;
            }
        }

        for (int yi = 0; yi < resolution.y - 1; yi++)
        {
            for (int xi = 0; xi < resolution.x - 1; xi++)
            {
                // 法線の計算
                float dudx = (waveTable[xi + 1, yi] - waveTable[xi - 1, yi]) / dx;
                float dudy = (waveTable[xi, yi] - waveTable[xi - 1, yi]) / dy;
                normals[xi + yi * resolution.x] = new Vector3(-dudx, 1.0f, -dudy).normalized;
            }
        }

        mesh.SetVertices(vertices);
        mesh.SetNormals(normals);
    }

    /// <summary>
    /// 現在の頂点座標の取得
    /// </summary>
    private Vector3 GetVertexPosition(int x, int y)
    {
        return new Vector3(
            (float) x / resolution.x * meshSize.x - meshSize.x / 2f,
            waveTable[x, y],
            (float) y / resolution.y * meshSize.y - meshSize.y / 2f
        );
    }
}


波動方程式の計算部分(抜粋)
    // 2次元波動方程式の計算 (FixedUpdate()から呼ぶ想定)
    void SolveWaveEquation(float deltaTime)
    {
        float dx = meshSize.x / resolution.x;
        float dy = meshSize.y / resolution.y;

        // 波の速度の計算
        for (int yi = 1; yi < resolution.y - 1; yi++)
        {
            for (int xi = 1; xi < resolution.x - 1; xi++)
            {
                float wave = waveTable[xi, yi]; // u(x, y)
                float waveX1 = waveTable[xi - 1, yi]; // u(x - dx, y)
                float waveX2 = waveTable[xi + 1, yi]; // u(x + dx, y)
                float waveY1 = waveTable[xi, yi - 1]; // u(x, y - dy)
                float waveY2 = waveTable[xi, yi + 1]; // u(x, y + dy)

                // (d/dx)^2 u     
                float dudx2 = (waveX1 + waveX2 - 2f * wave) / dx / dx;

                // (d/dy)^2 u     
                float dudz2 = (waveY1 + waveY2 - 2f * wave) / dy / dy;

                // 加速度(d/dt)^2 u の計算
                float waveAccel = s * s * (dudx2 + dudz2);

                // 加速度を使って速度を更新
                waveSpeedTable[xi, yi] += waveAccel * deltaTime;
            }
        }

        // 速度を使って位置を更新
        for (int yi = 1; yi < resolution.y - 1; yi++)
        {
            for (int xi = 1; xi < resolution.x - 1; xi++)
            {
                waveTable[xi, yi] += waveSpeedTable[xi, yi] * deltaTime;
            }
        }

        UpdateMesh();
    }


補足 : メッシュの法線の計算方法

水面メッシュの点$P(x,y,u(x,y))$ における長さ1の法線ベクトル $ \vec{n}$の計算方法を軽く紹介します。
法線ベクトルの計算は、C#では以下のような実装になっています。

// 法線の計算
float dudx = (waveTable[xi + 1, yi] - waveTable[xi - 1, yi]) / dx;
float dudy = (waveTable[xi, yi] - waveTable[xi - 1, yi]) / dy;
normals[xi + yi * resolution.x] = new Vector3(-dudx, 1.0f, -dudy).normalized;
\vec{n} = 
\begin{vmatrix}
- \Delta u_x / \Delta x \\
- \Delta u_y / \Delta y \\
1
\end{vmatrix}
\\\\
\Delta u_x = u(x + \Delta x, y) - u(x, y)
\\\\
\Delta u_y = u(x, y + \Delta y) - u(x, y)

法線ベクトルの導出(ちょっと長いです)

■法線ベクトルの導出

水面にある点$P$からx方向に少しずれた位置にある水面上の点 $Q$と、
y方向に少しずれた位置にある水面上の点 $R$ を考えます。
$\vec{PQ}$ と $\vec{PR}$ の外積を計算することで、点Pの法線方向のベクトルを得ることができます。

■法線の計算

点P, Q, R は以下のようなベクトル形式で表すことができます。

P = \begin{pmatrix}
x \\
y \\
u(x,y)
\end{pmatrix}
Q = \begin{pmatrix}
x + \Delta x \\
y \\
u(x+\Delta x,y)
\end{pmatrix}
R = \begin{pmatrix}
x \\
y + \Delta y \\
u(x,y + \Delta y)
\end{pmatrix}




$\vec{PQ}$ と $\vec{PR}$ は以下のようなベクトルになります。

\vec{PQ} = \begin{pmatrix}
\Delta x \\
0 \\
u(x+\Delta x,y) - u(x,y) 
\end{pmatrix}
= \begin{pmatrix}
\Delta x \\
0 \\
\Delta u_x
\end{pmatrix}
\vec{PR} =  \begin{pmatrix}
0 \\
dy \\
u(x,y+\Delta y) - u(x,y) 
\end{pmatrix}
= \begin{pmatrix}
0 \\
dy \\
\Delta u_y
\end{pmatrix}


$\vec{PQ}$ と $\vec{PR}$ の外積を計算すると、以下のようになります。

\vec{PQ} \times \vec{PR}  
= 
\begin{pmatrix}
\Delta x\\
0 \\
\Delta u_y
\end{pmatrix}
\times
\begin{pmatrix}
0 \\
\Delta y \\
\Delta u_y
\end{pmatrix}
=
\begin{pmatrix}
- \Delta y \Delta u_x \\
- \Delta x \Delta u_y \\
\Delta x \Delta y
\end{pmatrix}
=
\begin{pmatrix}
- \Delta u_x / \Delta x \\
- \Delta u_y / \Delta y \\
1
\end{pmatrix}
\Delta x \Delta y

$ \vec{PQ} \times \vec{PR} $ を正規化すると、係数の $ \Delta x \Delta y $ は消え、長さ1の法線ベクトル $\vec{n}$ を得ます。

| \vec{PQ} \times \vec{PR} |
= \begin{vmatrix}
- \Delta u_x / \Delta x \\
- \Delta u_y / \Delta y \\
1
\end{vmatrix}
= \vec{n}

z方向下向きの法線ベクトルが欲しいときは、-1倍します。

\begin{vmatrix}
\Delta u_x / \Delta x \\
\Delta u_y / \Delta y \\
-1
\end{vmatrix}

(法線ベクトルの導出おわり)



$5. C# Job Systemで波動方程式を実装する

§4. の 波動シミュレーションをJobSystemに移植します。

移植に当たって以下のようなcsファイルを用意しました。

csファイル名 説明
WaveParameter.cs 波のパラメータを保持する構造体
WaveSpeedJob.cs 波動方程式を計算し、波の速度を更新するジョブ
WavePositionJob.cs 波の速度を利用して、波の位置を更新するジョブ。 WaveSpeedJobの後に実行
WaveMesh2D_Job.cs 波の状態をMeshへ反映するMonoBehaviourクラス
WaveJobSystem.cs JobSystemを実行する大元のMonoBehaviourクラス

Unity上での実装を見たい方は、GitHubリポジトリをご覧ください
https://github.com/rngtm/Unity-JobSystem-WaveEquation

ソースコード

WaveParameter.cs (波のパラメータの構造体)
WaveParameter.cs
using System;
using UnityEngine;

/// <summary>
/// 波のパラメータ
/// </summary>
[Serializable]
public struct WaveParameter
{
    public int NumX; // グリッドの数(X)
    public int NumY; // グリッドの数(Y)
    public float DeltaX; // グリッド間の距離(X方向)
    public float DeltaY; // グリッド間の距離(Y方向)
    public float V; // 波が伝わる速さ
    public Vector2 MeshSize; // メッシュの大きさ
}

WaveSpeedJob.cs (波の速さを更新するジョブ)
WaveSpeedJob.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;

/// <summary>
/// 波の加速度・速度を計算するJob
/// </summary>
[BurstCompile]
public struct WaveSpeedJob : IJobParallelFor
{
    [ReadOnly] public WaveParameter Parameter;
    [ReadOnly] public NativeArray<float> WaveArray; // 波の変位u
    public NativeArray<float> Accel; // 波の加速度 (d/dt)^2 u
    public NativeArray<float> Speed; // 波の速さ (d/dt) u
    public float DeltaTime;

    public void Execute(int index)
    {
        int xi = index % Parameter.NumX;
        int yi = index / Parameter.NumX;

        // 端点の場合は何もしない
        if (xi == 0 || xi == Parameter.NumX - 1) return;
        if (yi == 0 || yi == Parameter.NumY - 1) return;

        float wave = GetWave(xi, yi);
        float waveX1 = GetWave(xi - 1, yi);
        float waveX2 = GetWave(xi + 1, yi);
        float waveY1 = GetWave(xi, yi - 1);
        float waveY2 = GetWave(xi, yi + 1);

        float d2ux = (waveX1 + waveX2 - 2f * wave) / (2f);
        float d2uy = (waveY1 + waveY2 - 2f * wave) / (2f);

        float dvdx = (Parameter.V / Parameter.DeltaX);
        Accel[index] = dvdx * dvdx * (d2ux + d2uy) * DeltaTime;
        Speed[index] += Accel[index] * DeltaTime;
    }

    float GetWave(int xi, int yi)
    {
        return WaveArray[xi + yi * Parameter.NumX];
    }
}

WavePositionJob.cs (波の位置(変位u)を更新するジョブ)
WavePositionJob.cs
using Unity.Burst;
using Unity.Collections;
using Unity.Jobs;

/// <summary>
/// 波のSpeedを元にして、波の変位uを更新するJob
/// </summary>
[BurstCompile]
public struct WavePositionJob : IJobParallelFor
{
    [ReadOnly] public float DeltaTime;
    [ReadOnly] public NativeArray<float> Speed; // 波の速さ (d/dt) u
    public NativeArray<float> Position; // 波の速さ (d/dt) u

    public void Execute(int index)
    {
        // 波の変位の更新
        Position[index] += Speed[index] * DeltaTime;
    }
}

WaveMesh2D_Job.cs (波のメッシュを管理するMonoBehaviourクラス)
WaveMesh2D_Job.cs
using System.Linq;
using Unity.Collections;
using UnityEngine;

/// <summary>
/// 波のメッシュを管理するクラス(C# JobSystemから動かす)
/// </summary>
[RequireComponent(typeof(MeshFilter))]
[RequireComponent(typeof(MeshRenderer))]
public class WaveMesh2D_Job : MonoBehaviour
{
    private WaveParameter parameter;
    private NativeArray<float> waveTable;
    private Vector2Int resolution; // メッシュ解像度
    private Mesh mesh = null;
    private Vector3[] vertices = null;
    private Vector3[] normals = null;

    /// <summary>
    /// 波の初期化
    /// </summary>
    public void Create(WaveParameter parameter, NativeArray<float> waveArray)
    {
        this.parameter = parameter;
        resolution = new Vector2Int(parameter.NumX, parameter.NumY);
        waveTable = waveArray;
        CreateMesh();
    }

    /// <summary>
    /// メッシュ作成
    /// </summary>
    void CreateMesh()
    {
        mesh = new Mesh();

        // 頂点の作成
        int vertexCount = resolution.x * resolution.y;

        // 頂点・法線・UV作成
        vertices = new Vector3[vertexCount];
        normals = new Vector3[vertexCount].Select(x => new Vector3(0, 1, 0)).ToArray();

        var uv = new Vector2[vertexCount];
        int vi = 0;
        for (int yi = 0; yi < resolution.y; yi++)
        {
            for (int xi = 0; xi < resolution.x; xi++)
            {
                vertices[vi] = GetVertexPosition(xi, yi);
                uv[vi] = new Vector2((float)xi / (resolution.x - 1), (float)yi / (resolution.y - 1));
                vi++;
            }
        }

        // 頂点インデックス作成
        int triangleCount = (resolution.x - 1) * (resolution.y - 1) * 6;
        int[] triangles = new int[triangleCount];
        int offset = 0;
        int ti = 0;
        for (int yi = 0; yi < resolution.y - 1; yi++)
        {
            for (int xi = 0; xi < resolution.x - 1; xi++)
            {
                triangles[ti++] = offset;
                triangles[ti++] = offset + resolution.x;
                triangles[ti++] = offset + 1;
                triangles[ti++] = offset + resolution.x;
                triangles[ti++] = offset + resolution.x + 1;
                triangles[ti++] = offset + 1;

                offset += 1;
            }

            offset += 1;
        }

        mesh.SetVertices(vertices);
        mesh.uv = uv;
        mesh.SetTriangles(triangles, 0);

        GetComponent<MeshFilter>().mesh = mesh;
    }

    /// <summary>
    /// メッシュ更新
    /// </summary>
    public void UpdateMesh()
    {
        float dx = parameter.MeshSize.x / resolution.x;
        float dy = parameter.MeshSize.y / resolution.y;
        int vi = 0;
        for (int yi = 0; yi < resolution.y; yi++)
        {
            for (int xi = 0; xi < resolution.x; xi++)
            {
                vertices[vi] = GetVertexPosition(xi, yi);
                vi++;
            }
        }

        for (int yi = 1; yi < resolution.y - 1; yi++)
        {
            for (int xi = 1; xi < resolution.x - 1; xi++)
            {
                // 法線の計算
                float dudx = (GetWave(xi + 1, yi) - GetWave(xi - 1, yi)) / parameter.DeltaX;
                float dudy = (GetWave(xi, yi) - GetWave(xi - 1, yi)) / parameter.DeltaY;
                normals[xi + yi * resolution.x] = new Vector3(-dudx, 1.0f, -dudy).normalized;
            }
        }

        mesh.SetVertices(vertices);
        mesh.SetNormals(normals);
    }

    private Vector3 GetVertexPosition(int x, int y)
    {
        return new Vector3(
            (float) x / resolution.x * parameter.MeshSize.x - parameter.MeshSize.x / 2f,
            GetWave(x, y),
            (float) y / resolution.y * parameter.MeshSize.y - parameter.MeshSize.y / 2f
        );
    }

    private float GetWave(int x, int y)
    {
        return waveTable[x + y * resolution.x];
    }
}

WaveJobSystem.cs (JobSystemを実行するMonoBehaviourクラス)
WaveJobSystem.cs
using Unity.Collections;
using Unity.Jobs;
using UnityEngine;

public class WaveJobSystem : MonoBehaviour
{
    [SerializeField] private WaveMesh2D_Job waveMesh = null;
    [SerializeField] private WaveParameter parameter = new WaveParameter();
    private NativeArray<float> accelArray;
    private NativeArray<float> speedArray;
    private NativeArray<float> waveArray;

    void Start()
    {
        // Native Arrayのメモリ割り当て
        int arrayLength = parameter.NumX * parameter.NumY;
        accelArray = new NativeArray<float>(arrayLength, Allocator.Persistent);
        speedArray = new NativeArray<float>(arrayLength, Allocator.Persistent);
        waveArray = new NativeArray<float>(arrayLength, Allocator.Persistent);

        // 波の初期状態の設定
        InitializeWave();

        // Mesh作成
        waveMesh.Create(parameter, waveArray);
    }

    private void FixedUpdate()
    {
        RunJob();
        waveMesh.UpdateMesh();
    }

    /// <summary>
    /// ジョブの実行
    /// </summary>
    private void RunJob()
    {
        float deltaTime = Time.fixedDeltaTime;
        var speedJob = new WaveSpeedJob
        {
            Accel = accelArray,
            Speed = speedArray,
            WaveArray = waveArray,
            Parameter = parameter,
            DeltaTime = deltaTime,
        };

        var speedHandle = speedJob.Schedule(speedArray.Length, 1);

        var positionJob = new WavePositionJob
        {
            Speed = speedArray,
            Position = waveArray,
            DeltaTime = deltaTime,
        };

        var positionHandle = positionJob.Schedule(speedArray.Length, 1, speedHandle);
        positionHandle.Complete();
    }

    /// <summary>
    /// 波の初期化
    /// </summary>
    private void InitializeWave()
    {
        Vector2 center = new Vector2(1f, 1f);
        int i = 0;
        for (int yi = 0; yi < parameter.NumY; yi++)
        {
            for (int xi = 0; xi < parameter.NumX; xi++)
            {
                var p = GetVertexPosition(xi, yi);
                float r = (new Vector2(p.x, p.z) - center).magnitude;
                float h = Mathf.Exp(-r * 8.0f) * 2.0f;
                h = Mathf.Clamp01(h);
                waveArray[i++] = h;
            }
        }
    }

    /// <summary>
    /// 頂点座標取得
    /// </summary>
    /// <param name="x"></param>
    /// <param name="y"></param>
    /// <returns></returns>
    private Vector3 GetVertexPosition(int x, int y)
    {
        return new Vector3(
            (float) x / parameter.NumX * parameter.MeshSize.x - parameter.MeshSize.x / 2f,
            GetWave(x, y),
            (float) y / parameter.NumY * parameter.MeshSize.y - parameter.MeshSize.y / 2f
        );
    }

    /// <summary>
    /// 波の取得
    /// </summary>
    /// <param name="x"></param>
    /// <param name="y"></param>
    /// <returns></returns>
    private float GetWave(int x, int y)
    {
        return waveArray[x + y * parameter.NumX];
    }

    /// <summary>
    /// NativeArrayの解放 (確保したNativeArrayは自分で開放する必要がある)
    /// </summary>
    private void OnDestroy()
    {
        waveArray.Dispose();
        speedArray.Dispose();
        accelArray.Dispose();
    }
}

JobSystemのBurst対応について

BurstCompileアトリビュートをJobの頭につけることで、Burst対応されます。

WavePositionJob.cs
[BurstCompile]
public struct WavePositionJob : IJobParallelFor
{
...

$6. ShaderGraphで水を描画する

水の描画方法は複数考えられます。
・地面のレンダリング結果に水面を上から重ねる
・地面レンダリングのUVを水面の法線でゆがませる
・光を水面で屈折させて地面を描画する
など

今回はRayを水面で屈折させて地面を描画することにしてみました。

Rayの屈折

Rayを水面で屈折させて、地面を描画します。

Ray_Fig_1.png

具体的な手順

1) Cameraから、水面メッシュへ向けてRayを飛ばす
2) メッシュ上の法線を利用して、Rayの向きを屈折させる
3) 屈折したRayが地面にぶつかった位置の座標をテクスチャ座標として利用して、地面テクスチャを描画する

ShaderGraphの実装

Fig_ShaderGraph.png

Ray向きの計算部分

image.png

Rayの屈折部分

ここではメッシュ法線を利用して、Rayの向きを屈折させています。
image.png

カスタムノード Refract

ShaderGraphには屈折させるノードは存在しないので、カスタムノードでRefract(屈折)ノードを作成しました。
image.png

カスタムノード Refractの実装について

カスタムノード Refractの実装

カスタムノードの中身では、Custom Functionで屈折処理を実装しています。
image.png

Refract
OutDir = refract(RayDir, Normal, eta);

RayDir, Normal について

RayDirはRayの向き、NormalはRayとメッシュが当たる位置の法線Nです。

Ray_Fig_1.png

etaについて

etaは屈折率の比を表しています。
空気(1.0)から水(1.333)へ入射する場合、はeta = 0.75になります。

eta = \frac{1.0}{1.333} \approx 0.75


Rayの進む距離の計算部分

image.png

ここでは、Rayが地面にぶつかるまでの距離を計算しています。
Fig3_Ray_Distance.png

Ray位置の計算

ここでは、Rayの地面上の位置を計算しています。
image.png

fig4_RayGroundPosition.png

地面テクスチャサンプリング処理

地面のRayのXZ座標を使って地面テクスチャをサンプリングしています。
image.png

サンプリング結果は、Albedoとして出力します。
image.png

結果

output.gif

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

ScriptableWizardからコンポーネントとプレハブを同時に作成する例

PONOS Advent Calendar 2020の2日目の記事です。

昨日は@kerimekaさんの【合格】Googleの認定資格、PCA(Professional Cloud Architect)を取得した話でした。

はじめに

最近、エディタ拡張機能のScriptableWizardを使用しアセットの作成を効率化する機会があったのですが、作業の中で少し工夫が必要な部分があったので記録として残しておこうと思います。

なお、本記事の実行環境はUnity 2019.4.8f1となります。

作成するもの

この記事で取り上げる成果物は「画面UIのプレハブと制御用のコンポーネントを同時に作成するウィザード」です。

例えば、ゲームを開発している中でタイトル画面表示用のプレハブと、その制御用のコンポーネントのスクリプトを同時に作成したいシチュエーションがあると思います。
単純な操作なので手作業で作成していくのもアリなのですが、「タイトル画面」以外にも「ホーム画面」や「メニュー画面」のように画面数が増えていくとその単純作業のコストも馬鹿にならず、また作成されるプレハブの構造やコンポーネントの内容を統一したい、など作成ルールが複雑化すると量産作業に余計な時間と神経を使うことになってしまいます。

そこで今回は、画面名(たとえばTitle)を指定して作成処理を実行するだけで、その画面名にあったプレハブ(Title.prefab)と制御用のコンポーネント(TitleViewController.cs)が同時に作成される機能を目指しました。プレハブへの制御用コンポーネントの追加を手動で実施する必要が無いように、制御用のコンポーネントがあらかじめプレハブに追加された状態にしておきます。
この機能を使用することで画面の量産作業は画面名を入力してボタンをクリックするだけとなり、負担を大幅に軽減できます。

画像でも補足しておきます。
以下のような作成ウィザードが立ち上がり、作成したい画面名を「View Name」フィールドに入力して「Create」ボタンをクリックすることで、
作成するもの-1.png
以下のようにプレハブとそれを制御するためのコンポーネントが作成されます。
作成するもの-2.png

ScriptableWizardについて

まずはじめにScriptableWizardについて少し触れておきます。

UnityEditor.ScriptableWizard - Unity スクリプトリファレンス

ScriptableWizardは「何かを作成するエディタウインドウ」を作成することに特化したエディタウインドウです。
以下のような特徴を持ちます。

  • シリアライズ可能なフィールドがエディタウインドウ上の入力フィールドとして表示される
  • 作成処理を実行するための「Create」ボタンがエディタウインドウ上に予め設置されている

ゲームオブジェクトやプレハブ、その他のアセットなどをパラメータ指定して作成するのに便利な機能となっています。

作成の手順

手順1. ScriptableWizardを作成する

まずは、ScriptableWizardクラスを継承したクラスを作成します。
ScriptableWizardクラスはEditorWindowクラスを継承しているため、通常のエディタウインドウを作成する時と作業はほとんど同じです。エディタウインドウの作成を経験されている場合、ここは特に難しい点はないと思います。

今回は画面を作成するウィザードということで「ViewCraeteWizard」というクラス名で以下のようなコードを作成しました。

using UnityEngine;
using UnityEditor;

public class ViewCreateWizard : ScriptableWizard
{
    [SerializeField]
    string viewName = string.Empty;

    [MenuItem("View/Create View")]
    static void CreateWizard()
    {
        // 作成ウィザードを表示する。
        DisplayWizard<ViewCreateWizard>("View Create Wizard");
    }
}

このコードで作成されるウインドウがこちらです。
手順1.png

とてもシンプルなコードですが、簡単に作成ウィザードのエディタウインドウを作成することができました。
GUIについてコーディングせずとも入力フィールドや作成ボタンが自動的に実装されており、非常にお手軽です。

手順2. コンポーネントを作成する

StringBuilderを利用してコードの文字列を作成し、UnityプロジェクトのAssetsフォルダ以下にC#スクリプトを作成します。
ScriptableWizardクラスを継承したクラスでは、OnWizardCreate()メソッド内にウィザードの「Create」ボタンがクリックされた時の処理を記述することができます。

void OnWizardCreate()
{
    string className = $"{viewName}ViewController";
    string path = $"{Application.dataPath}/{className}.cs";

    var builder = new StringBuilder();
    builder.AppendLine("using UnityEngine;");
    builder.AppendLine();
    builder.AppendLine($"public class {className} : MonoBehaviour");
    builder.AppendLine("{");
    builder.AppendLine("}");

    // 文字列を指定のパスに書き出す。
    File.WriteAllText(path, builder.ToString());

    // Unityのプロジェクトに反映する。
    AssetDatabase.Refresh();
}

File.WriteAllTtext()メソッドでスクリプトを書き出した後にAssetDatabase.Refresh()を呼び出しておかないと、UnityのProjectウインドウ上に即時反映されないため注意してください。

これで、以下のようにウィザードを入力して「Create」ボタンをクリックすると、
手順2-1.png
以下のようなファイルが作成されるようになりました。
手順2-2.png

また、ゲームオブジェクトの「Add Component」から選択することも可能です。
手順2-3.png

手順3. プレハブを作成する

次に、プレハブを作成します。
プレハブの作成には以下のようなコードを用意します。手順2のOnWizardCreate()メソッドに追記しています。

var gameObject = new GameObject(viewName);
var prefabPath = $"{viewName}.prefab";

// 指定したゲームオブジェクトをプレハブ化する。
PrefabUtility.SaveAsPrefabAsset(gameObject, prefabPath);

// プレハブ化したゲームオブジェクトをHierarchyから破棄する。
GameObject.DestroyImmediate(gameObject);

PrefabUtilityクラスのSaveAsPrefabAsset()を使用することで、引数のゲームオブジェクトをプレハブ化することができます。
なお、SaveAsPrefabAsset()を実行するためにはHierarchy上にゲームオブジェクトを作成する必要があるのでnew GameObject()していますが、プレハブ化した後は不要となるのでGameObject.DestroyImmediate()で削除しておきましょう。

この状態で作成処理を実行すると、コンポーネントとともにプレハブが作成されるようになりました。
手順3.png

手順4. プレハブへコンポーネントを追加する

さて、手順3でプレハブは作成できましたが、手順2で作成したコンポーネントをまだ追加できていません。
ここで少し工夫が必要となります。この手順を進めるためにはさらに2点の対応が必要となりました。

  1. 動的に作成されたコンポーネントの型を特定し、コンポーネントを追加する
  2. コンパイルが終了してからコンポーネントを取得する

動的に作成されたコンポーネントの型を特定し、コンポーネントを追加する

ViewCreateWizardで動的に作成された画面制御用のコンポーネントをプレハブへ追加したいのですが、動的に作成されるコンポーネントの型を直接参照することができないため、ジェネリックで追加するコンポーネントの型を指定するGameObject.AddComponent<T>()は利用できません。
そこで今回は、Typeオブジェクトを引数にしてコンポーネントの追加が行えるGameObject.AddComponent(Type componentType)を使用していこうと思います。Typeオブジェクトはクラス名の文字列から取得する想定です。このアプローチであれば、コンポーネントの型を直接参照できなくても、指定した型のコンポーネントの追加を実現できます。
(なお、同様にタイプ名文字列からコンポーネントの追加を行えるGameObject.AddComponent(string className)も存在しますが、こちらは非推奨になっているため採用しません)

以下のコードではAssemblyクラスを利用して、クラス名をキーにTypeオブジェクトを取得しています。
OnWizardCreate()メソッドに追記しています。

var gameObject = new GameObject(viewName);

// Assembly-CSharpアセンブリからクラスを取得する。
var assembly = Assembly.Load("Assembly-CSharp");
var componentType = assembly.GetType(className);

gameObject.AddComponent(componentType);

この処理でならプレハブへコンポーネントの追加ができそうです。
しかし、実はこの時点では動的に作成されたコンポーネントのコンパイルが完了していないため、アセンブリからTypeオブジェクトを取得することができません。コンポーネントの追加に「AddComponent asking for invalid type」の警告が発生して処理に失敗します。

コンパイルが終了してからコンポーネントを取得する

続いてはコンパイルが通ったあとにこれらのTypeオブジェクトの取得処理がが実行されるように修正していきます。

主な方針は、以下です。

  • コンパイルの完了後にViewCreateWizardクラスが再読み込みされるので、その時に作成処理を再開する。
  • クラスの再読み込み時にクラスに設定されたパラメータが破棄されてしまうので、EditorPrefsへ作成に関する情報を保存しておく。

まずは、ViewCreateWizardクラスへInitializeOnLoad属性を付与します。
これにより、再コンパイル完了後にstaticコンストラクタによる初期化処理が実行されるようになります。
staticコンストラクタ内ではプレハブ作成処理を再開するためのメソッド(ここではOnCompilationFinished()メソッドとする)を呼び出すようにしておきます。

[InitializeOnLoad]
public class ViewCreateWizard : ScriptableWizard
{
    static ViewCreateWizard()
    {
        OnCompilationFinished();
    }

クラスの再読み込みが実施された時にクラス内のパラメータが破棄されてしまうので、作成処理の再開時に情報を引き継ぐためにあらかじめEditorPrefsへ作成に関する情報を逃がしておきます。
今回、画面の作成に必要な情報は画面名だけなので、そちらをEditorPrefs.SetString()で保存しておきます。

void OnWizardCreate()
{
    // 作成情報をEditorPrefsへ保存しておく。
    EditorPrefs.SetString("CreatingViewName", viewName);

    string className = $"{viewName}ViewController";
    string path = $"{Application.dataPath}/{className}.cs";

    var builder = new StringBuilder();
    builder.AppendLine("using UnityEngine;");
    builder.AppendLine();
    builder.AppendLine($"public class {className} : MonoBehaviour");
    builder.AppendLine("{");
    builder.AppendLine("}");

    File.WriteAllText(path, builder.ToString());

    AssetDatabase.Refresh();
}

プレハブの作成処理を再開するメソッドは以下のように記述します。
上で提示したアセンブリからのTypeオブジェクトの取得処理を利用し、コンポーネントをプレハブへ追加しています。

static void OnCompilationFinished()
{
    if (!EditorPrefs.HasKey("CreatingViewName"))
    {
        return;
    }

    // 作成情報をEditorPrefsから読み込む。
    var creatingViewName = EditorPrefs.GetString("CreatingViewName");
    string className = $"{creatingViewName}ViewController";

    var gameObject = new GameObject(creatingViewName);
    var assembly = Assembly.Load("Assembly-CSharp");
    var classType = assembly.GetType(className);
    gameObject.AddComponent(classType);

    var prefabPath = $"{creatingViewName}.prefab";
    PrefabUtility.SaveAsPrefabAsset(gameObject, prefabPath);
    GameObject.DestroyImmediate(gameObject);

    // 作成が済んだので、EditorPrefsから削除する。
    EditorPrefs.DeleteKey("CreatingViewName");
}

ここで改めてウィザードから作成を実行すると、

  1. コンポーネントのクラスの作成
  2. コンパイルを待機
  3. コンパイル終了後にプレハブの追加

が順に実行されます。
以下の画像のように、目的としていた動的に作成されたコンポーネントが追加された状態のプレハブを作成することができました!
手順4.png

まとめ

ScriptableWizardはパラメータ入力に関するGUIの配置をサポートしてくれるので、エディタウインドウのGUI操作に関するコーディングを最低限で済ませることができます。その分、作成処理のコーディングへ集中することができるので、今回の例のように多少複雑な作成処理を実装したいときには有用な機能であると感じました。
これを機に今後もScriptableWizardを使って量産作業の効率化を図っていきたいと思います。

明日は@nissy_gpさんです!

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

Unityで2つのアプリをUDPでローカル通信させる

はじめ

デュアルモニターで2つのアプリを起動。
片方をお客様用。片方はスタッフ用のような使い方を前提にしています。

コード

UDPマネージャー

UdpManager.ts
using System;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using UnityEngine;

namespace Model.UserDatagrams
{
    public class UdpManager : MonoBehaviour
    {
        public delegate void Callback(UdpData udpData);

        private Callback _callbacks;

        public void AddCallback(Callback callback)
        {
            _callbacks += callback;
        }

        private const string Host = "localHost";
        private const int ReceivePort = 10011;
        private const int SendPort = 10012;
        private const string AppType = "app01";
        private UdpClient _client;
        private Thread _thread;

        private void Start()
        {
            _client = new UdpClient();
            _client.Connect(Host, SendPort);
            _thread = new Thread(Receive) {IsBackground = true};
            _thread.Start();
        }

        /// <summary>
        /// UDPで送信
        /// </summary>
        /// <param name="text">送信コメント</param>
        public void Send(string text)
        {
            var data = new UdpData()
            {
                AppType = AppType,
                Text = text
            };
            var json = JsonUtility.ToJson(data, true);
            var dgram = Encoding.UTF8.GetBytes(json);
            _client.Send(dgram, dgram.Length);
        }

        private void Receive()
        {
            var receiver = new UdpClient(ReceivePort);
            //古い書き方のかなぁ。エラーが出る。
            //receiver.Client.ReceiveTimeout = 1000;
            while (true)
            {
                try
                {
                    IPEndPoint anyIp = null;
                    var bytesData = receiver.Receive(ref anyIp);
                    var text = Encoding.UTF8.GetString(bytesData);
                    var udpData = JsonUtility.FromJson<UdpData>(text);
                    _callbacks(udpData);
                }
                catch (Exception err)
                {
                    Debug.Log("catch :" + err);
                }
            }
            // ReSharper disable once FunctionNeverReturns
        }
        private void OnApplicationQuit()
        {
            _thread.Abort();
        }
    }
}




git
https://github.com/kawamurashin/UnityUDPManagerTest

サンプルアプリ

Git https://github.com/kawamurashin/UnityUdpManagerApp1

片方だけのアプリだけでは操作できないように、両アプリ起動をコントロールします。

アプリ起動後の準備中として相手のアプリの状況を確認します。。
準備中は、文字列"ready"を受信したら、"ready"を送り返すようにします。

そしてまず、こちらから"ready"という文字列を送ります。
なにも帰ってこなければ、相手アプリは起動してないので準備中のままにします。
文字列"ready"が帰ってくれば、相手アプリは起動しているので、システム開始します。

その後の準備中に"ready"が送り返されたら、相手アプリが起動したということです。
文字列"ready"を送り、システム開始します。

まとめ

とりあえず通信はできました。
送受信に2つのPortを用意しないでめでした。
もう少しスマートになるのかしらね。

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

なぜRigidbodyオブジェクトをTransformで移動させてはいけないのか

こんにちは。ソウタロウです。
今回は私が駆け出しゲームエンジニアだった時(数年前)にハマった、UnityにおけるRigidbodyオブジェクトの移動制御について書こうと思います。

単語の定義

弊社は定義するのが好きという話を小耳にはさんだので、まずは本記事に出てくる単語の定義から記載します。

  • Rigidbodyオブジェクト:Rigidbodyコンポーネントをつけたオブジェクト

以上

本記事の概要

UnityではRigidbodyコンポーネントをオブジェクトにつけることで簡単に物理演算が可能になります。
重力、スリープ状態の閾値等のパラメータが簡単に設定できるのでとても便利なのですが、オブジェクトの移動制御に関して、注意しなければならない点があります。
本記事では、Rigidbodyオブジェクトの移動をtransformで制御してはいけない理由を解説していきます。

Rigidbodyオブジェクトをrigidbody.positionで移動させた場合

まずは正しく動作するパターンを紹介します。Rigidbodyコンポーネントをつけた球体に坂を上らせてみましょう。
添付動画のように、意図した挙動で移動を制御できています。
スクリプトは下記のとおりで、Dキーを押しています。

hoge.cs
void FixedUpdate(){
         if (Input.GetKey(KeyCode.D))
             rb.position += new Vector3(0.15f, 0, 0);
     }

ezgif.com-video-to-gif.gif

Rigidbodyオブジェクトをtransform.positionで移動させた場合

次に、右矢印キーを押して坂を上らせてみましょう。
坂にめり込んでしまっていますね。前述のスクリプトを見ても、意図した挙動でないのは明らかです。
ezgif.com-video-to-gif-2.gif

hoge.cs
void Update () {
         if (Input.GetKey(KeyCode.RightArrow))
             this.transform.position += new Vector3(0.15f, 0, 0);
     }

なぜめり込んでしまうのか

上記のgifでそれぞれの挙動が異なることは理解できたかと思います。
では、なぜtransform.positionだとめり込んでしまうのか。
答えは Unityのスクリプトライフサイクルのフローチャートを読むと分かります。
本記事に関連する部分のみ抜粋すると、「FixedUpdate内の物理演算」→「Update内の演算」→「レンダリング」の順で処理されるイメージです。

つまり、Rigidbodyオブジェクトをtransform.positionで移動させた場合、物理演算が走る前にレンダリングされているということです。
コライダー同士が重なった場合の座標の修正が行われる前にレンダリングされているので、坂にめり込んでしまっていたのですね。

まとめ

Rigidbodyオブジェクトはrigidbodyを使ってFixedUpdate()内で制御しましょう(例外あり)。

あとがき的なもの

初めてアドベントカレンダーに参加し、初めてQiitaに投稿しました。大変ではありましたが、私用PCを数か月ぶりに開いて、Unityを起動したときのワクワクは新鮮なものがありました。この機会をくれた同期に感謝です。
ゲーム創りは楽しいことばかりではありません。勉強しなければいけないことが本当にたくさんあります。
現在、私はゲームプランナーとしてゲーム創りをしていますが、プランナーとエンジニアという分野は違えど、本記事が多くのゲームクリエイターの役に立てばと思います。
より良いゲームをたくさん創って、より多くの人にゲームの素晴らしさを届けたいですね。

====================================================
この記事を読んで「面白かった」「学びがあった」と思っていただけた方、よろしければ Twitter や facebook、はてなブックマークにてコメントをお願いします!
また DeNA 公式 Twitter アカウント @DeNAxTech では、 Blog記事だけでなく色々な勉強会での登壇資料も発信してます。ぜひフォローして下さい!

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

ASTCテクスチャが一部のAndroid端末で透ける問題について

はじめに

Happy Elements Advent Calendar 2020 6日目の記事です。

Unity でのテクスチャフォーマットに関する記事です。テクスチャ圧縮のひとつであるASTCは圧縮率が高く綺麗なフォーマットですが、リニア色空間のプロジェクトでは一部の Android 端末でアルファ値がわずかに低下する問題があります。この問題の原因はGPUにあるようなので、今後の Unity エンジンの更新で問題が解決される可能性は低いです。そのため、アルファブレンディングかつ透けるとまずい部分には Android 向けにASTCテクスチャを使わないことをおすすめします。

ASTCフォーマットについて

テクスチャのインポート設定において、プラットフォームごとのフォーマット指定をする欄で RGB(A) Compressed ASTC NxN block という選択肢が用意されています。

1.png

4x4 〜 12x12 の決まった大きさのブロック単位で圧縮を行い、ブロックには固定のビット数が割り当てられているので、ブロックが大きいほど圧縮率が高くなります。無圧縮と見分けがつかないほど綺麗な 4x4 でも圧縮率は8bit/ピクセルであることから、効率がよく、なおかつ圧縮率の微調整が可能な非常に使い勝手の良いフォーマットです。アルファチャンネルにも対応しています。1

また、プラットフォームの対応状況についても近年対応端末の割合が高くなってきています。iOS は iPhone6 以上、Android は OpenGL ES 3.2、OpenGL ES 3.1+AEP 対応GPUまたは OpenGL ES 3.0 対応GPUの一部となっており、数年以内に発売された主要な種類のGPUは概ね対応していると考えて良さそうです。

問題の発生状況

以下の条件をすべて満たすとき問題が発生します。

  • Adreno GPU 搭載端末(実機)
  • プロジェクト設定はリニア色空間
  • テクスチャフォーマットは任意のブロックサイズのASTC
  • テクスチャ自体の色空間はsRGB

ビルドされたアプリに同梱されたアセットかアセットバンドルかの違いや、グラフィックAPIが OpenGL ES か Vulkan かの違いは関係ないようです。

例として次の画像を RGBA 32bit、ASTC 4x4 block の2種類のフォーマットでインポートし、適当な模様の上にアルファブレンドで描画してみましょう。元画像の円盤内部はアルファ値が1となっています。

texture.png

RGBA:
0_RGBA.png

ASTC:
0_ASTC.png

このように、ASTCでは特に暗い色の円盤が透けて後ろの模様が見えてしまっています。2

モバイルアプリでアルファブレンドが多用されるところとして、UI、2Dアニメーション、パーティクルシステムなどが挙げられます。このうちUI、2Dアニメーションについてはアルファ値が1の部分は後ろが透けて見えない前提で制作することがほとんどだと思うので、パーツの接合部分その他見えてはいけないものが見えてしまうなど、問題が顕在化してしまうことでしょう。

なお、3Dモデルのアルベドテクスチャとして使う場合など、不透明シェーダーで描画するときは当然透けることはありません。

対処法

これからテクスチャフォーマットを決める段階であれば、Android ではUIや2Dアニメーションなどに ASTC を使わないというのが無難です。代わりに ETC2 を検討すると良いでしょう。なお、ETC2 には画像サイズが4の倍数でなければならないという制限があるので注意してください。既に ASTC でビルドしたアセットを無差別に ETC2 で再ビルドするのは危険です。

もしも既に大量のテクスチャをビルドしてしまっていて変更が難しければ、以下のような対処法も考えられます。

  • 不透明マテリアル/シェーダーに変更する
    • アルファブレンディング自体が不要な場合は Unlit/Texture のような不透明なマテリアルに変更すると良いでしょう。ただし描画順の制御的に uGUI との相性は悪いです。
    • uGUI の中で不透明で描画したい場合(背景を1枚の画像が覆うUIなど)、uGUI のデフォルトマテリアルではなくテクスチャのアルファ値を無視するシェーダーを作成して設定すると良いかも知れません。
  • フラグメントシェーダー内でアルファ値を少し大きくする
    • 非常に場当たり的な方法ですが、アルファ値を 1.01 倍くらいすれば、低下したアルファ値を回復させることができます。

Issue Tracker

Unity にバグ報告しましたが、問題の原因がGPUにあるということで、Unity 的には仕様ということになりました。

Unity Issue Tracker - Android The alpha channel value of a ASTC Compressed sRGB textures is always slightly less than 1.0 Adreno GPU's

メンバー募集

Happy Elements株式会社 カカリアスタジオでは、
いっしょに【熱狂的に愛されるコンテンツ】をつくっていただけるメンバーを大募集中です!
もし弊社にご興味持っていただけましたら、是非一度
下記採用サイトをご覧ください。
Happy Elements株式会社 採用特設サイト


  1. Unity 2018 まではインポート設定が RGB と RGBA で分かれています。RGB を選択するとアルファチャンネルが無視されて全体のアルファ値が1となりますが、その場合もこの記事の問題は回避できないのでご注意ください。問題が発生すると、全体的にわずかに透明になります。 

  2. リニア色空間の時のみ透ける、暗い色ほど透けるなどの性質は、リニアで計算された色が画面に表示される際ガンマに変換されることを考慮すると説明できそうです。 

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

UnityのTerrainで地形を作る ~60分で作るジオラマ地形~

初めに

 この記事は「サムザップ #2 Advent Calendar 2020」の12月2日目の記事です。
 昨日の記事は@RyotoKitajimaさんの「Sumzap Engineering Vision を作ってから約半年が経ちました」でした。

 この記事ではUnity標準機能のTerrainについて解説いたします。
 読者の皆さんにはぜひ自分で実際に手を動かしつつ、Terrainの「地形生成」「テクスチャペイント」「木や草の生やし方」の基本的な項目についてこの記事で学んでもらえればと思います。

作成する地形

 今回サンプルとして作成する地形はこちらです。

gif_animation_00208h54m.gif

 水面と木や草のモデルはLowPolyWaterNatureStarterKit2の物を使用し、島の形や木と草の配置はTerrainを用いて行っています。今回は約60分でこのジオラマ地形を作りつつ、Terrainの基礎を学んでいきます。

Terrain作成

それでは早速地形を生成していきましょう。

環境

 使用したバージョンは以下の通りです。

Unity 2019.4.10f1
LowPolyWater 1.0.0
NatureStarterKit2 1.0

地形の生成

 空のシーンを作り、Hierarchyから右クリックで3D Object>Terrainを選択しTerrainを生成します。

Create.png

 生成されたTerrainは以下のような平面の地形です。

スクリーンショット 2020-11-30 006.png

 生成直後の初期の状態では非常に広大な地形が生成されているため、地形全体の大きさを調整します。
 TerrainのInspecterから歯車アイコンのTerrainSettingを選択し、MeshResolutionからTerrain WidthTerrain Lengthを100にしましょう。

スクリーンショット 2020-11-29 224149.png

TerrainSettingWH.png

 それでは地形を想定している形に変えて行きましょう。
 TerrainのInspecterから筆アイコンのPaintTerrainを選択し、ドロップダウンからRaiseorLowerTerrainを選択します。

TerrainSetting2.png
RaiseLowerTerrain.png

 これで地形編集の準備が完了しました。
 それでは実際に地形を編集していきましょう。
 地形を編集するにはBrusheからブラシを選んでBrushSizeでブラシの大きさを、Opacityでブラシの影響力を設定し、Scene上で地形を隆起させていきます。
 左クリックで地形が山になり、シフトと左クリックを同時に押すと地形が谷になります。

image.png

 この状態でマウスを地形上に持って行くと編集後の地形が見えるため、この地形を参考に進めていくと良いでしょう。
 ある程度形になったら次に進んでいきます。

image.png

 地形を上下させるブラシのみで島を作った場合、島の大きさは適切でも凹凸が大きい地形になることがしばしばあります。
 そんなときのために、ブラシで地形を滑らかにする方法があるので紹介します。
ドロップダウンからSmoothHeightを選択し、滑らかにしたい部分にブラシを当てて調整していきましょう。

image.png

 ある程度スムースをかける事で以下のように余分な凹凸の少ない滑らかな地形を作る事ができます。

image.png

 ここまでで地形の編集は完了です。
 次は地形にテクスチャを塗っていきます。ドロップダウンからPaintTextureを選択します。

image.png

テクスチャの設定

 次にテクスチャを設定します。EditTerraiLayers>CreateLayerを選択し緑のテクスチャのground01を適応します。
 再度EditTerraiLayers>CreateLayerを選択し、次は浜辺用にground02を選択してください。
 選択が終わったら下の画像のようになっているはずです。

image.png

image.png

 先に緑のテクスチャを設定したため、地形全部が緑色になっています。
 浜辺と海の部分をground02で塗っていきます。
 塗り方は地形編集で行った方法と同じくブラシを使って塗っていきます。
 必要な部分をOpacityの値を100で塗り、後でOpacityを2程度まで下げて境界線を薄く塗っていくとより自然に見えます。

image.png

image.png

水面の作成

 次は水面を作りましょう。
 今回はアセットストアからLowPoly Waterを導入し、水面を作りました。

スクリーンショット 2020-11-30 075454.png

 水面のオブジェクトは地形と同じ大きさのPlaneを用意し、そのPlaneにLowPoly WaterLowPolyWaterMaterialをD&Dする事で作成できます。
 作成後、Y軸の位置を上下させて位置を調節してください。

image.png

草を生やす

 次に草を生やしていきます。
 後に登場する草や木はNatureStarterKit2を使用しています。
 PaintDetailアイコンを選択し、EditDeails>AddGrassTextureからAddGrassTextureウィンドウを開きます。
 DetailTextureに今回使用する草のテクスチャのbush01を設定し、Addボタンを押します。

TerrainSetting4.png
EditDetail.png
スクリーンショット 2020-11-30 075454.png

 Detailsから追加したbush01を選択し、草を生やしていきます。
 ペイントと同じようにブラシを選択し、Settingからブラシの大きさや影響力を調整しながら生やしていきましょう。
GrassBrushe.png
image.png

木を生やす

 次は木を生やしていきます。
 PaintTreesアイコンを選択し、Trees>EditTreesからAddTreeウィンドウを開きます。
 TreePrefabに今回使用する木のtree03を設定し、Addボタンを押します。

TerrainSetting3.png
treesetting.png
tree03.png

 Treesから追加したtree03を選択し、木を生やしていきます。
 木を生やすときのSettingsは今までと違い、TreeHeightという項目が追加されています。
 この部分は木の高さを設定する部分になっており、右側のバーの位置と幅を設定する事で、その範囲内の高さの木が生えます。Random?のチェックマークを外すことで固定値に設定する事もできます。
 それでは実際に木を生やしていきましょう。
TreesSetting.png
image.png

茂みを生やす

 最後に茂みを生やしていきましょう。
 木と同じようにTrees>EditTreesからAddTreeウィンドウを開きます。
 TreePrefabにbush03を設定し、Addボタンを押して追加します。

image.png

 追加が完了するとTreesでbush03が選択できるようになります。
 同様にbush04も追加し、木の時と同じく設置していきましょう。

bush0304.png

 ここまでの操作で以下のような島を作る事ができました。

gif_animation_00208h54m.gif

まとめ

 Terrainによる地形生成の基礎と「地形生成」「テクスチャペイント」「木や草の生やし方」の基本的な使い方について紹介しました。
 これを読んだ読者が地形生成の最初の一歩を踏み出せたら幸いです。

最後に

 明日は@kojima_akiraさんの「CIサービスはもういらない!?github actionsでphp-cs-fixerを使ってみる」です。

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

[Unity]60分で作るTerrainジオラマ地形

初めに

 この記事は「サムザップ #2 Advent Calendar 2020」の12月2日目の記事です。
 昨日の記事は@RyotoKitajimaさんの「Sumzap Engineering Vision を作ってから約半年が経ちました」でした。

 この記事ではUnity標準機能のTerrainについて解説いたします。
 読者の皆さんにはぜひ自分で実際に手を動かしつつ、Terrainの「地形生成」「テクスチャペイント」「木や草の生やし方」の基本的な項目についてこの記事で学んでもらえればと思います。

作成する地形

 今回サンプルとして作成する地形はこちらです。

gif_animation_00208h54m.gif

 水面と木や草のモデルはLowPolyWaterNatureStarterKit2の物を使用し、島の形や木と草の配置はTerrainを用いて行っています。今回は約60分でこのジオラマ地形を作りつつ、Terrainの基礎を学んでいきます。

Terrain作成

それでは早速地形を生成していきましょう。

環境

 使用したバージョンは以下の通りです。

Unity 2019.4.10f1
LowPolyWater 1.0.0
NatureStarterKit2 1.0

地形の生成

 空のシーンを作り、Hierarchyから右クリックで3D Object>Terrainを選択しTerrainを生成します。

Create.png

 生成されたTerrainは以下のような平面の地形です。

スクリーンショット 2020-11-30 006.png

 生成直後の初期の状態では非常に広大な地形が生成されているため、地形全体の大きさを調整します。
 TerrainのInspecterから歯車アイコンのTerrainSettingを選択し、MeshResolutionからTerrain WidthTerrain Lengthを100にしましょう。

スクリーンショット 2020-11-29 224149.png

TerrainSettingWH.png

 それでは地形を想定している形に変えて行きましょう。
 TerrainのInspecterから筆アイコンのPaintTerrainを選択し、ドロップダウンからRaiseorLowerTerrainを選択します。

TerrainSetting2.png
RaiseLowerTerrain.png

 これで地形編集の準備が完了しました。
 それでは実際に地形を編集していきましょう。
 地形を編集するにはBrusheからブラシを選んでBrushSizeでブラシの大きさを、Opacityでブラシの影響力を設定し、Scene上で地形を隆起させていきます。
 左クリックで地形が山になり、シフトと左クリックを同時に押すと地形が谷になります。

image.png

 この状態でマウスを地形上に持って行くと編集後の地形が見えるため、この地形を参考に進めていくと良いでしょう。
 ある程度形になったら次に進んでいきます。

image.png

 地形を上下させるブラシのみで島を作った場合、島の大きさは適切でも凹凸が大きい地形になることがしばしばあります。
 そんなときのために、ブラシで地形を滑らかにする方法があるので紹介します。
ドロップダウンからSmoothHeightを選択し、滑らかにしたい部分にブラシを当てて調整していきましょう。

image.png

 ある程度スムースをかける事で以下のように余分な凹凸の少ない滑らかな地形を作る事ができます。

image.png

 ここまでで地形の編集は完了です。
 次は地形にテクスチャを塗っていきます。ドロップダウンからPaintTextureを選択します。

image.png

テクスチャの設定

 次にテクスチャを設定します。EditTerraiLayers>CreateLayerを選択し緑のテクスチャのground01を適応します。
 再度EditTerraiLayers>CreateLayerを選択し、次は浜辺用にground02を選択してください。
 選択が終わったら下の画像のようになっているはずです。

image.png

image.png

 先に緑のテクスチャを設定したため、地形全部が緑色になっています。
 浜辺と海の部分をground02で塗っていきます。
 塗り方は地形編集で行った方法と同じくブラシを使って塗っていきます。
 必要な部分をOpacityの値を100で塗り、後でOpacityを2程度まで下げて境界線を薄く塗っていくとより自然に見えます。

image.png

image.png

水面の作成

 次は水面を作りましょう。
 今回はアセットストアからLowPoly Waterを導入し、水面を作りました。

スクリーンショット 2020-11-30 075454.png

 水面のオブジェクトは地形と同じ大きさのPlaneを用意し、そのPlaneにLowPoly WaterLowPolyWaterMaterialをD&Dする事で作成できます。
 作成後、Y軸の位置を上下させて位置を調節してください。

image.png

草を生やす

 次に草を生やしていきます。
 後に登場する草や木はNatureStarterKit2を使用しています。
 PaintDetailアイコンを選択し、EditDeails>AddGrassTextureからAddGrassTextureウィンドウを開きます。
 DetailTextureに今回使用する草のテクスチャのbush01を設定し、Addボタンを押します。

TerrainSetting4.png
EditDetail.png
スクリーンショット 2020-11-30 075454.png

 Detailsから追加したbush01を選択し、草を生やしていきます。
 ペイントと同じようにブラシを選択し、Settingからブラシの大きさや影響力を調整しながら生やしていきましょう。
GrassBrushe.png
image.png

木を生やす

 次は木を生やしていきます。
 PaintTreesアイコンを選択し、Trees>EditTreesからAddTreeウィンドウを開きます。
 TreePrefabに今回使用する木のtree03を設定し、Addボタンを押します。

TerrainSetting3.png
treesetting.png
tree03.png

 Treesから追加したtree03を選択し、木を生やしていきます。
 木を生やすときのSettingsは今までと違い、TreeHeightという項目が追加されています。
 この部分は木の高さを設定する部分になっており、右側のバーの位置と幅を設定する事で、その範囲内の高さの木が生えます。Random?のチェックマークを外すことで固定値に設定する事もできます。
 それでは実際に木を生やしていきましょう。
TreesSetting.png
image.png

茂みを生やす

 最後に茂みを生やしていきましょう。
 木と同じようにTrees>EditTreesからAddTreeウィンドウを開きます。
 TreePrefabにbush03を設定し、Addボタンを押して追加します。

image.png

 追加が完了するとTreesでbush03が選択できるようになります。
 同様にbush04も追加し、木の時と同じく設置していきましょう。

bush0304.png

 ここまでの操作で以下のような島を作る事ができました。

gif_animation_00208h54m.gif

まとめ

 Terrainによる地形生成の基礎と「地形生成」「テクスチャペイント」「木や草の生やし方」の基本的な使い方について紹介しました。
 これを読んだ読者が地形生成の最初の一歩を踏み出せたら幸いです。

最後に

 明日は@kojima_akiraさんの「CIサービスはもういらない!?github actionsでphp-cs-fixerを使ってみる」です。

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

#Unity のオブジェクトと #toio を IoTの仕組み(MQTT)でリアルタイムに連動させる仕組み( #ロボやろ )

この記事は、下記の toio のコンテストに Unity超初心者がエントリーして、コンテスト用の作品の試作を行った過程を記事にしたものです。

●【追記:審査員&制作期間延長決定!】本日10/10エントリー開始!「ロボットやろうぜ!- toio & Unity 作品動画コンテスト -」 | toio blog | toio(トイオ)
 https://toio.io/blog/detail/20201010-1.html
ロボやろ.jpg

試作した内容は、IoT関連の話でよく登場する MQTT を使い、Unity内のオブジェクトと現実世界の toio の動きを連動させる、というものです。以下のツイートや、YouTube に動画をアップしています。

●toio と Unity の位置座標連動の仕組みを試作してみた( #toio #Unity #ロボやろ ) - YouTube
 https://www.youtube.com/watch?v=Fic0unaSpw0

そして、 #ヒーローズリーグM5Stack のコンテスト等でも利用され、また自分が作った作品を登録することもできる ProtoPedia(プロトペディア)への登録も行ってみました。

●toioとUnityの位置座標連動の仕組みを試作してみた( #toio #Unity #ロボやろ ) | ProtoPedia
 https://protopedia.net/prototype/1989

全体構成

冒頭で動画を掲載していた今回の試作内容について、全体構成は以下のとおりです。

構成図(toio、Unity).jpg

そして、おおまかな処理の流れは以下となります(※ 処理の流れの中に書いてある用語等は、その後の部分で補足していきます)。

  1. toio が開発用プレイマット上に置かれた状態で位置が変わると、その位置座標がリアルタイムに BLE 通信で PC へと送られる
  2. PC上で動いているプログラム(JavaScriptによる実装)が toio の位置座標を受信し、その座標の情報を PC上の MQTTブローカーへ送信する
  3. MQTTブローカーは JavaScript で実装されたプログラムから受信した位置座標の情報を、Unity の C# で実装されたプログラムへ送る
  4. C# で実装されたプログラムは、受信した toio の位置座標の値に応じて、Unity内のオブジェクトの位置を変更する

以下で、上記の流れの個々の項目について、概要を記載します。

toio と Unity の間の通信

冒頭で、toio と Unity の間を MQTT を使ってつないでいると書きましたが、その MQTT を用いた通信では「MQTTブローカー」と呼ばれる仲介役が必要です。
この MQTTブローカーを提供しているクラウドサービス(クラウド版の shiftr.io 等)もあるのですが、インターネットを介すると遅延が発生するため、今回の構成ではローカル環境に自前で MQTTブローカーをたてました(詳細は後述しますが、Mosquitoデスクトップ版の shiftr.io の 2つを試しています)。

toio の制御の部分

toio と PC の通信には BLE を用い、その処理は Web Bluetooth API等を利用した HTML+JavaScript の上で実装しています。今回の仕組みに Web Bluetooth API を利用した理由ですが、これは自分が過去に作品を作った際のノウハウがあったためです(その際、機械学習を使う「Teachable Machine」での実装には HTML+JavaScript を使うのが都合が良かった、という背景がありました)。

上記の動画は過去の toio のキャンペーンの際に出したものですが、この動画でも使っている toio の制御やその前段階のペアリングの仕組みは、その前後で「Tsukuba Mini Maker Faire 2020」・「Maker Faire Tokyo 2020」の出展用に作った作品等でも使っていました。
そして、開発用プレイマット と toio を組み合わせた場合の toio との間での情報のやりとりを含め、以下のような記事としてノウハウを残していました。

さらに、JavaScript のプログラムには、toio から受信した位置座標の情報を、MQTTブローカーへ送信する処理も実装しています。

Unity側の処理の実装

Unity側で MQTT を扱う部分とオブジェクトの位置を変更する部分のプログラムは、C# で実装しています。

冒頭にも書いたとおり、自分が Unity の超初心者であるため、コンテスト用の試作を行うにあたり Unity側の仕組みを作り始める部分は悩ましいところでした。これについては、事前調査を行った結果 Unity の公式チュートリアルの 1つを活用して作るのが良さそうだと思われました。

具体的には、以下の「玉転がし(Roll-a-Ball)」のチュートリアルを途中まで進めたものを使いました。
C# のコードが出てくるのは以下の 1つ目のチュートリアルですが、壁や床を作ったり等のオブジェクトを取り扱う部分は以下の 2つ目のチュートリアルも共通で、そちらのほうが見やすかったため、そちらを用いました。

●玉転がし - Unity Learn
 https://learn.unity.com/project/yu-zhuan-gashi?language=ja

●Unity入門チュートリアル 「玉転がし」(Roll-a-Ball) ビジュアルスクリプティング版 - Unity Learn
 https://learn.unity.com/project/bolt-roll-a-ball-tutorial

玉転がし_-_Unity_Learn.jpg

ここから、今回の実装の詳細について補足していきます。

構成要素の詳細

以下で、MQTT関連・toio関連・Unity関連のそれぞれの部分について、詳細を記載していきます。
まずは、toio・Uniy の実装にも登場する MQTT の部分について説明します。

MQTTブローカーに関する部分

今回、自分は MQTT による通信の仲介役となる MQTTブローカーとして、Mac上でデスクトップ版の shiftr.io を用いるパターンと Mosquito を用いるパターンの 2つを試しました。
どちらか一方のみ用いれば良いのですが、それぞれを利用する方法について簡単に補足します。

デスクトップ版の shiftr.io を使う

今まで、無償利用も可能な MQTTブローカーを提供していた shiftr.io が、最近、デスクトップ版のアプリをリリースしました。詳細は以下の記事にも書いたのですが、これを使う方法について大まかな内容を記載します。

●MQTTブローカー shiftr.io の新バージョンをチェックしてみる! 〜デスクトップアプリ編〜 - Qiita
 https://qiita.com/youtoy/items/8cbc9e6fa1cd46ca2c3c

利用手順は簡単で、以下のとおりです。

  1. デスクトップ版の shiftr.io のページの下部より、デスクトップ版アプリをダウンロード(※ Windows/Mac/Linux用の 3つがあります)
  2. アプリをインストールし起動
  3. アプリを起動した状態で MQTT の処理を実行

1つだけ注意点があり、上記 2)で起動したアプリ(以下の図のもの)は、接続用のポート番号として「1883/1884」の 2つが用意され、場合によっては使い分ける必要がでてきます。
shiftr_io_Desktop.jpg

それぞれ、「MQTT over TCP」用と「MQTT over WebSocket」用になるのですが、今回はその両方を使い分けて利用することになります。とりあえずは、これ以降に出てくる toio用の処理(JavaScript実装)では「1884」、Unityの処理(C#実装)では「1833」と覚えておいてください。

Mosquito を使う

Mac で Mosquito をインストールする際には、Homebrew を用いました(Homebrew の導入方法については公式ページの説明等をご覧ください)。具体的なインストール用のコマンドは brew install mosquito となります。

これを実行する場合、 mosquito というコマンドのみでも起動できるのですが、それだと「MQTT over TCP」用のポート番号「1883」だけしか利用できません。デスクトップ版の shiftr.io の説明で書いていた「MQTT over WebSocket」用の通信も今回は行う必要があるため、もう1手間必要です。

テキストエディタ等で、以下の内容のコンフィグファイルを作成してください。ファイル名は何でも良いのですが、ここでは「mosquitto.conf」という名前とします。

mosquitto.conf
listener 1883

listener 1884
protocol websockets

そして、Mosquito の実行時にオプションを指定します。具体的には、 mosquito -c mosquitto.conf となります。これで、toio用の処理(JavaScript実装)で用いる「MQTT over WebSocket(ポート番号 1884)」と、Unityの処理(C#実装)で用いる「MQTT over TCP(ポート番号 1833)」の両方が利用可能な状態で Mosquito が立ち上がります。

toio関連の部分

HTML+JavaScript による実装

toio に関する部分は、「toio との通信(BLE での接続、マット上の位置座標を取得)」と「MQTT による通信」を主に実装します。HTML+JavaScript の上で実装したと記載していましたが、具体的なソースコードは以下となります(簡単のため、今回はファイルを分けず 1ファイルで作成しました)。

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MQTT で toio の位置座標を送る</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.1/css/bulma.min.css">
<script src="https://unpkg.com/mqtt@4.1.0/dist/mqtt.min.js"></script>
</head>

<body>
  <section class="section">
    <div class="container">
      <h1 class="title is-5">
        toio用のボタン
      </h1>
      <div class="buttons">
        <button id="b1" class="button is-success is-light">接続</button>
        <button id="b2" class="button is-danger is-light">切断</button>
      </div>
</section>

<script>

const MQTTURL = 'ws://127.0.0.1:1884'; //ローカル環境で用意した MQTTブローカーの接続先指定(ポート番号 1884)
const TOPIC = 'test'; // MQTT のトピックは Unity側と合っていれば何でも良い
let options = {
  clientId: 'Browser-'+Math.floor(Math.random() * 100) // MQTT のクライアント ID を生成
};

let client = mqtt.connect(MQTTURL, options); // MQTTブローカーへ接続

// MQTTブローカーに接続された場合に実行される処理
client.on('connect', function(){
  console.log('MQTTブローカーに接続完了');
  // 今回は必須ではないが、MQTT の Subscribe の処理も実装しておく
  client.subscribe(TOPIC, function(err, granted) {
    if (err) {
      console.log('subscribe の処理に失敗:', err);
    } else {
      console.log('subscribe の処理に成功');
    }
  });
});

// MQTTブローカーからメッセージを受信した際に実行される処理
client.on('message', function(topic, message){
    console.log('subscribe ⇒ ', 'トピック:', TOPIC, 'メッセージ:', message.toString());
});

// MQTTブローカーにメッセージを送るための処理
function pubMQTT(message) {
  client.publish(TOPIC, message);
  console.log('publish:メッセージ ⇒ ' + message);
}

// ライブラリを使わずボタン押下時の処理を紐付ける設定を行う
const b1 = document.getElementById("b1");
const b2 = document.getElementById("b2");
b1.addEventListener("click", function(){onConnectButtonClick(cube)}, false);
b2.addEventListener("click", function(){onDisconnectButtonClick(cube)}, false);

// BLE通信用: toio関連の UUID(toio との接続用+座標情報の受信用)
const TOIO_SERVICE_UUID          = '10b20100-5b3b-4571-9508-cf3efcd7bbae'; // 仕様: https://toio.github.io/toio-spec/docs/ble_communication_overview
const ID_SENSOR_CHARACTERISTICS_UUID = '10b20101-5b3b-4571-9508-cf3efcd7bbae'; // 仕様: https://toio.github.io/toio-spec/docs/ble_sensor

// 今後、複数の cube を利用する際にあると便利かもしれない部分
const cube = {
  deviceCube: undefined,
  idSensorChar: undefined
};

// BLE で toio に接続するための処理
async function onConnectButtonClick(cube) {
  let serviceUuid = TOIO_SERVICE_UUID;
  let characteristicUuid;

  try {
    console.log('Bluetoothデバイスの検索開始');
    cube.deviceCube = await navigator.bluetooth.requestDevice({
        // toio のみをスキャン結果に出すためのフィルター
        filters: [{services: [serviceUuid]}]
    });
    console.log('接続処理1');
    const server = await cube.deviceCube.gatt.connect();
    const service = await server.getPrimaryService(serviceUuid);

    console.log('接続処理2:座標情報を受け取るための処理');
    cube.idSensorChar = await service.getCharacteristic(ID_SENSOR_CHARACTERISTICS_UUID);
    // マット上の座標情報などが toio から通知されるように設定
    await cube.idSensorChar.startNotifications();
    console.log('通知を開始');
    cube.idSensorChar.addEventListener('characteristicvaluechanged',
        handleNotifications);
  } catch(error) {
    console.log('接続処理に失敗! ' + error);
  }
}

// toio からの通知を受け取った場合に実行される処理
function handleNotifications(event) {
  let value = event.target.value;

  // 受信したバイナリのデータから情報を座標情報を読み取る
  // 関連する仕様: https://toio.github.io/toio-spec/docs/ble_id
  if(value.getUint8(0).toString(16)==='1') {
    outputReadValue(value); // デバッグ用にログを出力
    console.log("キューブの中心の X "+value.getUint16(1, true)); // リトルエンディアン、キューブの中心の X 座標値
    console.log("キューブの中心の Y "+value.getUint16(3, true)); // リトルエンディアン、キューブの中心の Y 座標値
    pubMQTT(value.getUint16(1, true) + "," + value.getUint16(3, true)); // MQTT で座標情報を送信する
  } else if(value.getUint8(0).toString(16)==='3'){
    console.log('マットの上にのってないよ');
  }
}

// toio の切断処理をするためのもの
function onDisconnectButtonClick(cube) {
  if (!cube.deviceCube) {
    return;
  }
  console.log('Bluetoothデバイスを切断');
  if (cube.deviceCube.gatt.connected) {
    cube.deviceCube.gatt.disconnect();
  } else {
    console.log('既に切断済みです');
  }
}

// デバッグ用情報をまとめて出力するための処理
// 関連する仕様: https://toio.github.io/toio-spec/docs/ble_id
function outputReadValue(readValue) {
  console.log("キューブの中心の X "+readValue.getUint16(1, true)); // リトルエンディアン、キューブの中心の X 座標値
  console.log("キューブの中心の Y "+readValue.getUint16(3, true)); // リトルエンディアン、キューブの中心の Y 座標値
  console.log("キューブの角度 "+readValue.getUint16(5, true)); // リトルエンディアン、キューブの角度
  console.log("読み取りセンサーの X "+readValue.getUint16(7, true)); // リトルエンディアン、読み取りセンサーの X 座標値
  console.log("読み取りセンサーの Y "+readValue.getUint16(9, true)); // リトルエンディアン、読み取りセンサーの Y 座標値
  console.log("読み取りセンサーの角度 "+readValue.getUint16(11, true)); // リトルエンディアン、読み取りセンサーの角度
}

</script>
</body>
</html>

HTML の部分は、ヘッダ内で「bulma.min.css」と「mqtt.min.js」を読み込み、ボディの中では 2つのボタンを用意しています。

「bulma.min.css」は見た目を整えるために読み込んだ、CSSフレームワークの「Bulma」です。また「mqtt.min.js」は MQTT の通信処理を行うためのライブラリである「MQTT.js」です。

ボタン 2つを用意するところの周りで、class に section や container 等が指定されていますが、これは Bulma に関わる部分です。今回の本質的な部分ではないため、ここでは詳細説明は省きますが、気になる方は公式のドキュメントや Web で検索して出てくる記事などをご参照ください。

また、JavaScriptで実装された内容については、ソースコード内のコメントを参照ください。

使い方

上記のファイルを利用する際は、ブラウザ上で開いてください。ローカルに置いているファイルをそのまま開く形で OK です。

以下は Google Chrome で開いた際の表示例です。

Chromeでファイルを開いた状態.jpg

このページの「接続ボタン」を押す前に、 toio の電源を ON にしてください。
toio の電源が入り、接続処理を行えるようになった状態でこの「接続ボタン」を押します。

そうすると、以下のような表示が出てくるので、以下の画像中の ① ⇒ ② の順にクリックしてください。
そうすると、toio とのペアリングが行われ、うまくいくと toio から音が鳴ります。

toioのペアリング.jpg

また、ソースコードでログを表示する処理をいくつか入れていたため、ブラウザのコンソールを開くと例えば以下のような表示が確認できるかと思います(以下は、Google Chrome の開発者ツールを開き、コンソールを表示させた際の様子)。

開発者ツールのコンソール.jpg

あとは、MQTTブローカーも立ち上がった状態で、ペアリング済みの toio を開発用プレイマットにのせると、マット上の座標情報が MQTT で送信され、コンソールにログが表示されます。
今回、JavaScript で実装したソースコードで MQTT の送信側だけでなく受信側も実装したため、Unity側の準備ができていない状態でも、以下のようにログの中で MQTTブローカーを介した送受信の両方が行えていることが確認できます。

MQTTでPub・Sub_JavaScriptのみ.jpg

Unity関連の部分

地面・床・壁やプレイヤー等を準備する(コーディング以外の部分)

今回、Unity側の実装には公式チュートリアルを活用しました。
そして上で記載したとおり、地面・床・壁やプレイヤーの準備といった C# でのコーディング以外の部分は、以下を用いました。

●Unity入門チュートリアル 「玉転がし」(Roll-a-Ball) ビジュアルスクリプティング版 - Unity Learn
 https://learn.unity.com/project/bolt-roll-a-ball-tutorial

こちらは、プログラミングの部分をビジュアルスクリプティングの仕組みである BOLT を使う形になっているため、一部の内容は除きつつ進めて行きます。まずは、以下の内容を進めます。

上記の「Scene ビューの移動方法」の後に「Bolt のインストール」という項目があるのですが、今回は不要となる部分ですのでスキップしてください。
また、上記の「プレイヤーの追加」の中では、項目が 1 から 6 まで用意されていますが、「3.ゲームを再生する」の部分まで進めれば OK です(※ 4 から 6 は BOLT に関する内容となっているため)。

余談ですが、当初はこのチュートリアルで扱われていたビジュアルスクリプティングの「Bolt」も組み合わせようと思って、今回はスキップすると書いた部分も試作の過程では試したりしました。

C# による実装

これまでの手順で、Unity側のオブジェクトは準備ができました。
ここからは、MQTT を受信する処理と、受信した情報をもとに上記手順で作成したプレイヤーの位置を変える処理を実装します。

まず、MQTT関連の処理を行うために、MQTTクライアントライブラリを追加します。
ライブラリが複数ありそうだったのですが、以下の記事の記載を参考にしつつ MQTTnet を採用することにしました。

●MQTTnet を Unity で使う - Qiita
 https://qiita.com/johnson65t/items/230360b4cec41e8aafa4
●UnityからMQTTブローカに接続し、メッセージをUIとして表示させる - ゆべねこの足跡
 https://yubeshicat.hatenablog.com/entry/2019/02/10/033005

導入手順についても、上記の記事を参照しました。
手順としては、最初にライブラリを利用できるようにするために、NuGet Galleryから MQTTnet のパッケージをダウンロードしてきます。MQTTnet がいろいろありますが、上記の記事の両方で利用されていた 2.8.5 のパッケージを試すことにしました。
ダウンロードされたパッケージは展開をして、.NET 4.7.2 向けの DLL ファイル(mqttnet.2.8.5 > lib > net472 の中にあるもの)をプロジェクトにインポートします(MQTTnet.dll を、今回の Unityプロジェクトの Assets/Plugins/ 以下にインポートしてください)。
これで MQTTnet を利用するための準備ができました。

上記のライブラリの準備も完了したので、Unity側で C# のプログラムを書いていきます。
今回、MQTT で値を受け取ったら即座にオブジェクトの位置を変更する、という処理を実装したかったのですが、上記の 2つの記事にはオブジェクトの位置の変更に該当する部分がなさそうでした。そこで、他の記事を検索して探し、WebSocket を用いた事例ですが今回のオブジェクトの位置変更に活用できる内容が含まれた以下の記事を見つけることができました。

●WebブラウザとUnityをfirebaseで連携させたインタラクティブコンテンツ - KAYAC engineers' blog
 https://techblog.kayac.com/web-firebase-websocket-unity

そして、これらの記事などを参考にして実装したソースコードは以下となります。
処理の説明については、ソースコード中のコメントをご参照ください。

using UnityEngine;
using System;
using System.Text;
using System.Threading.Tasks;
using MQTTnet;
using MQTTnet.Client;

public class PlayerController : MonoBehaviour {
    private Vector3 position;

    // MQTT用の設定
    IMqttClient mqttClient;
    string url = "127.0.0.1";
    int port = 1883;  // MQTT over TCP

    async void Start () {
        // オブジェクトの位置操作用に準備
        position = transform.position;

        // MQTTクライアントの作成
        var factory = new MqttFactory();
        mqttClient = factory.CreateMqttClient();

        var options = new MqttClientOptionsBuilder()
            .WithClientId(Guid.NewGuid().ToString())
            .WithTcpServer(url, port)
            .Build();

        // MQTTブローカーとの通信の接続・切断時に行われる処理
        mqttClient.Connected += (s, e) => Debug.Log("接続したときの処理");
        mqttClient.Disconnected += async (s, e) => {
            Debug.Log("切断したときの処理");
            if (e.Exception == null) {
                Debug.Log("意図した切断です");
                return;
            }
            Debug.Log("意図しない切断です。5秒後に再接続を試みます");

            await Task.Delay(TimeSpan.FromSeconds(5));
            try {
                await mqttClient.ConnectAsync(options);
            }
            catch {
                Debug.Log("再接続に失敗しました");
            }
        };

        // MQTTブローカーからメッセージを受け取った時の処理
        mqttClient.ApplicationMessageReceived += (s, e) => {
            string payload = Encoding.UTF8.GetString(e.ApplicationMessage.Payload);
            Debug.Log("Payload = " + payload);

            // toioのx座標・y座標は「,」で連結して送っていたので、これを分離
            string[] xz = payload.Split(',');

            // toioの開発用プレイマットの座標を、Unity内の座標にマッピングする
            position = new Vector3(
                Map(float.Parse(xz[0]), 100, 400, -8, 8),
                0.5f,
                Map(float.Parse(xz[1]), 140, 355, 8, -8)
            );
        };

        // MQTTブローカーへの接続等
        await mqttClient.ConnectAsync(options);
        await mqttClient.SubscribeAsync(new TopicFilterBuilder().WithTopic("test").Build());

        // Unity側からのPublishも後々のために試しに実装
        var message = new MqttApplicationMessageBuilder()
            .WithTopic("test")
            .WithPayload("Unity側からのメッセージ送信")
            .WithExactlyOnceQoS()
            .Build();
        await mqttClient.PublishAsync(message);
    }

    void Update() {
        // 更新された座標情報を適用する
        transform.position = position;
    }

    async void OnDestroy() {
        await mqttClient.DisconnectAsync();
    }

    // toioの開発用プレイマットの座標を、Unity内の座標にマッピングするための処理
    float Map(float value, float start1, float end1, float start2, float end2) {
        return start2 + (end2 - start2) * ((value - start1) / (end1 - start1));
    }
}

実行結果

上記の仕組みを実際に動かしてみた時の様子が、記事の冒頭にも掲載していた以下の動画です。

toio の動きに連動して、リアルタイムに Unity内のオブジェクトの位置が変わっているのがご覧いただけるかと思います。

今後の応用のために

まとめに入る前に、今回作った仕組みを今後発展させていくための方向性について、少し書いておこうと思います。

ゲーム的な要素

今回、実装しきれませんでしたが、構想としては以下のような内容を考えていました。

  • toio をコントローラーにしたゲーム
    • Unity側で敵キャラを出現させて弾よけゲーム的なものにする(+回収すべきアイテムを出現させたり)
      • 敵キャラとぶつかったら、Unity側から toio側へ MQTT で情報を送って、toio側でモーター制御等を使ったアクションを起こさせる
      • 敵キャラとぶつかったら、Unity側から M5Stack等の別デバイスに情報を送り、デバイス側で光(LEDテープを利用)・振動(モーター等を利用)など物理的なフィードバックをする演出を加える
  • toio を複数台使ったゲーム
    • toio 1台が操作用で残りがマット上を動くお邪魔キャラになり、それらをマット上で避けつつ Unity側で用意されたゲームをこなす
  • インターネットを介して通信するもの
    • MQTTブローカーをインターネット上のものにし、遠隔地の toio 同士で協力プレイ/対戦プレイをするような仕組みにする
    • MQTTブローカーをインターネット上のものにし、遠隔地のスマホ・PC上の操作をネット越しに送り、toio で遊ぶゲームのお邪魔キャラを操作して対戦する仕組みにする

ただ上記について、難易度が高くなりすぎそうだったり、操作性が悪くなりそうだったり、という方向のものがあるため(※ 特に、Unity の画面と開発用プレイマット側との両方を常に見ていないといけなくなりそうな部分)、仕組みは整理して検討しないといけない部分も多々ありそうだと考えています。

まとめ

今回、toio と Unity 内のオブジェクトの位置座標を、MQTT を用いた通信でリアルタイムに連動させる仕組みを実装してみました。

自分が Unity に慣れていないため、連動させる仕組みを試作した後の応用までは、今回手をつけることができませんでした。今後、上記の「今後の応用」で書いたような Unity側でのゲーム的要素等の追加実装や MQTT を活用した様々なデバイスとの連携の仕組みなど、追加していければと思っています。

また、Unity連携に限らず、toio を活用した様々な仕組みを作っていければと思います。

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

toio SDK for Unity ~Standard IDでIKZO~

toio SDK for Unity ~StandardでIKZO~

toio SDK for Unity が公開されて早数ヶ月、触る触ると言いながら実際あまり触れていない状況だったりします。さて、今回は定番中の定番である、Standard IDを公式のサンプルを見ながら進める甘口編です。

お品書きはこんな感じ。
- toio SDK for Unityの環境設定
- Standard IDの使い方のしらべ
- 出来たもの

toio SDK for Unity の環境設定

toio SDK for Unityの環境設定はかなり丁寧に解説されています、心折れない。
ただバージョンだけは合わせた方が良いのでしっかりその通りに仕掛けよう!
環境設定についてはこちら
SDKバージョン(2020.11.30時点)
toio SDK for Unity ver.1.0.2
推奨バージョン(2020.11.30時点)
iOS端末(iOS ver.12以上)
Unity(2019.4 LTS)

ちなみにMacの人は若干楽だったりします。下記の順番で進めていきます。
事前準備
インストールとサンプルの実行
チュートリアル等

はじめにこれは理解しておかないとダメなものがいくつかあります。
シミュレータの操作方法
それぞれのPrefabの内容や構成図、やったらアカンことも書かれています。マットは「トイコレ付属マット」や「開発用マット」なども細かく選択できて、このシュミレータが以下に細部まで作られていてすごいのかを理解します。

スクリプトの依存関係
初心者からすると何のことかさっぱりなアセンブリ定義についてが書かれています。
忙しい人は下記を意識すればOkです。

そのため、toio-sdk/Scripts にソースコードを置いてしまうと、ユーザースクリプトを参照出来ずにコンパイルエラーになる場合があります。

このような不具合を未然に防ぐために、Assets フォルダの下に開発用フォルダを作成して、そのフォルダ内に新しいファイルを追加していく事をオススメします。

絶対にtoio-sdk/Scriptsには置いてはいけません。大事な事なので(仕様変わるかもしれませんけど。)

シーン作成

基礎環境を構築するアレコレが書かれています。こちらを進めていけば開発の準備はOKです!

Standard IDの使い方のしらべ

さて本題のStandard IDについてです。
toio™コア キューブ 技術仕様2.2.0にある通り色々なマットや各種カード・シートの情報を読取る事が出来ます。

ちなみにサンプルはこちらから参照可能です(Webで実行されます)
toio_idのサンプル
toioID.gif
※6. toio IDの読み取り(Position ID & Standard ID)より転載

手元にあるマットを用意して見ましょう!
Assets/toio-sdk/Tutorials/1.Basic/4.toioID/

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

namespace toio.tutorial
{
    public class toioIDScene : MonoBehaviour
    {
        float intervalTime = 0.1f;
        float elapsedTime = 0;
        Cube cube;
        bool started = false;

        async void Start()
        {
            var peripheral = await new NearestScanner().Scan();
            cube = await new CubeConnecter().Connect(peripheral);
            started = true;
        }

        void Update()
        {
            if (!started) return;

            elapsedTime += Time.deltaTime;

            if (intervalTime < elapsedTime) // 0.1秒ごとに実行
            {
                elapsedTime = 0.0f;

                // 手法A: y 座標で発光の強度を決める
                var strength = (510 - cube.y)/2;
                // 手法B: x 座標で発光の強度を決める
                // var strength = (510 - cube.x)/2;
                // 手法C: pos と中央の距離で発光の強度を決める
                // var strength = (int)(255 - (cube.pos-new Vector2(255,255)).magnitude);

                // Standard ID によって発光の色を決める (初期値は0)
                if (cube.standardId == 3670337) // Simple Card "A"
                    cube.TurnLedOn(strength, 0, 0, 0);
                else if (cube.standardId == 3670080) // toio collection skunk yellow
                    cube.TurnLedOn(0, strength, 0, 0);
                else if (cube.standardId == 3670016) // toio collection card typhoon
                    cube.TurnLedOn(0, 0, strength, 0);
                else cube.TurnLedOff();
            }
        }
    }
}

出来たもの

今回は色を変えるだけじゃなく音を鳴らしてみましょう。音といえば音楽。音楽といえば演歌、演歌と言えばIKZOです(大分飛んだ)怪しいライセンスのIKZOを今回は使用します。
ニコニコモンズ~IKZO~

Unityで音を鳴らすのはいくつか方法がありますが今回はあまり複雑でない方法で行きます。
AudioSourceコンポーネントをつけ、Audio Clipを再生する

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

namespace toio.tutorial
{
    public class IKZOsample : MonoBehaviour
    {
        public AudioClip sound1;
        public AudioClip sound2;
        public AudioClip sound3;
        public AudioClip sound4;
        public AudioClip sound5;
        AudioSource audioSource;
        float intervalTime = 3.0f; //ここは少し長めに3秒へ。0.1だと非常にダメ幾三
        float elapsedTime = 0;
        Cube cube;
        bool started = false;
        async void Start()
        {
            audioSource = GetComponent<AudioSource>();
            var peripheral = await new NearestScanner().Scan();
            cube = await new CubeConnecter().Connect(peripheral);
            started = true;
        }

        void Update()
        {
            if (!started) return;

                elapsedTime += Time.deltaTime;

            if (intervalTime < elapsedTime) // 
            {
                elapsedTime = 0.0f;
                if (cube.standardId == 3670320) // 開発者用マット "0"
                    audioSource.PlayOneShot(sound1);
                   else if (cube.standardId == 3670321)  // 開発者用マット "1"
                    audioSource.PlayOneShot(sound2);
                   else if (cube.standardId == 3670322)  // 開発者用マット "2"
                    audioSource.PlayOneShot(sound3);
                   else if (cube.standardId == 3670323) // 開発者用マット "3"
                    audioSource.PlayOneShot(sound4);
                   else if (cube.standardId == 3670324) // 開発者用マット "4"
                    audioSource.PlayOneShot(sound5);
                   else if (cube.standardId == 3670325) // 開発者用マット "5"
                    audioSource.Stop();
            }
        }
    }
}

出来たものいざ。

今日が良い一日になりますように…

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

toioで12台使ってダンスさせたハナシ

こんな感じのコンテンツを作った時の話です。
Image from Gyazo

フル動画 → Unity-Chan AR Concert with toio dancers

アイディア出し

最初「ロボットやろうぜ!- toio & Unity 作品動画コンテスト」の話を聞いた時考えたのが、以下のアイディアでした。

  • 多数台のロボットでダンスしたら面白いんでは?
  • それも音楽のビートに合わせてやったらアガるんでは?
  • 後半、↓な感じに動かしたらさらにアガるんでは?

特に3つ目が是非やりたかったんですねー(それが後に地獄をみることになるんですが...)

で最大何台繋がるか?というのが疑問だったのですが、toioのUnity SDK開発されているモリカトロンさんが直々にやっているのを参考にしたところ

https://tech.morikatron.ai/entry/2020/04/23/150000

iPhone 11でトライしたところ、無事に12台の鬼ごっこが、スムーズに動くようになりました。

と書いてあったので、勝手に

「つまり、iPhone11で12台なら、iPhone12なら12台以上もいけるんでは!?いや、きっと余裕だろう!」

と脳内翻訳して、とりあえず16台買ってみました。
ところが16台toioもなかなかなくて、結局ソニーストアで大量に注文することになりました。

で届くまでシミュレーションでUnityエディタ上でガシガシ作っていったんです。

音楽にロボットを合わせるために

ビートに合わせるんだったら、以前買ったこれが役に立つだろうということで、

RhythmTool
https://assetstore.unity.com/packages/tools/audio/rhythmtool-15679?locale=ja-JP
Image from Gyazo
これ、すごいんですよ。ビートのタイミングを勝手に検出してくれるので、音ゲーとかが簡単に作れるというもの。これを使ってビートのタイミングで動かすと言うことを考えたのです。

要はビートの瞬間に動かす→少し進んだら止める→待機→再びビートが来たら動かす

ということをすればダンスしている風に見えるんじゃないかと。

実際に作ったところ、なんか北朝鮮マスゲーム風になってきたけど、まあそれはそれで良いかなという感じ。

レールに沿って動かす

マスゲーム的にするんであれば、いろんな形に動いたら良いんじゃないか、ということで以下のスプラインアセットを使いました。

Curvy Spline
https://assetstore.unity.com/packages/tools/utilities/curvy-splines-7038?locale=ja-JP
今回使ったパス
これ、使ってよかったです。後から「あーあそこやっぱりもう少し移動をこうしたいな」思った時に、手軽にスプラインを修正するだけで済むので大変便利でした。
あと、スプライン曲線自体をPrefabにしておくだけで、その形状を使った他の箇所も連動してくれるので、変更の手間が最小限で済みました。

状態遷移を扱う

で、ですね。

toio Unity SDKを見ていくと、SDKの思想が
「マネージャーが全てを統括する」
という感じのC言語的な感じを受けたのです(個人的感想です)。
12台のtoioを制御するのも1つのマネージャークラスがそれを制御する、と。

いやー、それはキツいんじゃないかな、と感じたわけです。今回はそれぞれ一つ一つが自律しながら、協調するという複雑なことをやらせたいわけです。

今回は状態遷移を扱うわけだから、やはりtoio1台に対して一つの状態遷移を扱えないと手に負えないだろうと。なおかつ全体を統括する状態遷移も必要だと。

そこでやはり状態遷移はコーディングするんではなくて、ビジュアルスクリプトでやっていきたいわけです。

なぜ状態遷移はコーディングするのではなくビジュアルスクリプトしたいのか、というと

  • 状態遷移は時が経つにつれて変異するので、コーディングでは見通しが悪い
  • ビジュアルスクリプトは現在の状態が「見て」わかるので、デバッグしやすい
  • 容易に修正できてビルドする必要がない

で、以前使っていたarborを使う選択肢もあったのですが、

arbor 3
https://assetstore.unity.com/packages/tools/visual-scripting/arbor-3-fsm-bt-graph-editor-112239?locale=ja-JP

今回は、ちょうどUnityがBoltを買収したということで、Bolt無料になったということで
Bolt
https://assetstore.unity.com/packages/tools/visual-scripting/bolt-163802?locale=ja-JP
今回使った状態遷移図
これを機会にBoltを本気で使ってみようということにしました。

実機で確認したらトラブル続出

それで、ようやく16台のtoioが到着したわけなのですが、実際にやってみると、まーいろんなトラブルが起こるわけです。

トラブル1: Boltでエラーが出て実機で動かない

さあこれでiPhone用にビルドして動かしてみるかーと、実機で動かそうとしたところ以下のエラーが出ました。

ExecutionEngineException: Attempting to call method XXX for which no ahead of time (AOT) code was generated.

こういう時は落ち着いて、エラー文をコピーしてググるのが一番ですね。
以下のサイトに書いてました。

【Unity】Boltで作ったゲームでAOTのエラーが出るときの対処
https://ekulabo.com/bolt-aot-pre-build

つまり、BoltをiPhoneで使う場合は「Tools > Bolt > AOT Pre-Build」を事前にしておけよ、ということでした。
Pre-Buildしてみて、再度ビルド&インストールしたところ正常に繋がる動作が始まりました!

トラブル2: 16台は繋がらなかった

まず16台繋がらない。
それ以前に12台も繋がらない。繋がるのは8台だけでした。

「おかしい… ちょっと試した時は確実に12台繋がったのに、後でやったら8台以上行かない…。そんなバカな話があるだろうか?」

いよいよおかしいので、iPhoneのBluetoothを再起動させたら、12台までは繋がるようになりました。
というわけで、
多数台繋げれられない場合は、iPhoneのBluetoothを再起動
を守りましょう。
あと、
toioがiPhoneに繋がるのは12台まで
ということが確定されました(2020/11/29現在)。まあ今後SDKの改善修正が入ったりすればできるかもしれませんが...。

トラブル3: シミュレーションと実機とで動きが違う

1台2台で動かしている時はまあそんなに気にならないのですが、12台で動かしているとなんか動きがカクカクするのです。そうなると肝心の後半の連携した動きがうまくいかないのです。完全に連携した動きじゃないと必ずぶつかってしまうのです。
Image from Gyazo

トラブル4: いきなりBluetooth通信が切れる

トラブル3と合わせて問題となったのが、いきなり通信が切れてしまうのです。エラーメッセージは出ているのですが詳しい内容まではわかりません。

以下のドキュメントを見てみると、どうやら Move2Targetに代表されるClosed-Loopメソッド は通信量が多いようです。

https://github.com/morikatron/toio-sdk-for-unity/blob/develop/docs/usage_cubehandle.md#25-closed-loop-メソッド

通信料が多いことでトラブル3と4が起こっているのではないかと予想しました。

通信量をいかに下げるか

Closed-Loopメソッドは呼び続けるから通信量が多くなるようです。
一方、One-Shotメソッドというのは一回だけ呼ぶので、通信料が減るようです。

しかし、ではOne-Shotメソッドに全て変更すればOK、というわけにはいかないのです。
One-Shotメソッドはその名の通り呼びっぱなしなので、最終的な到着地が指定できません。

つまり

A地点に行け

とClosed-Loopメソッドで指定できたのに、

右にX度回転して、前に10ぐらい進んで

というようにOne-Shotメソッドで指定するわけです。
(ちなみに距離の指定も謎の単位系で、「10cm」とかで指定できない)

これでは細かい多数台連携などできるわけがありません。

ではどうしたか。

正確を要する場面はClosed-Loopメソッドで、とりあえず適当で良い場面はOne-Shotメソッドでという混合で行うことにしました。(具体的に書くと、直線でガーっと行く場面では必ず指定の場所へ行って欲しいのでClosed-Loopメソッドで到達地点を指定し、その後回転して次のスタートラインに着くまではOne-Shotメソッドでなんとかたどり着かせる)

そして、Closed-Loopメソッドも極力連続で呼ばないように非同期で分散して呼ぶようにしました。

つまり、全体的に通信量を下げたわけです。

それでようやくちゃんと動くようになりました。

Image from Gyazo

作業を終えて

ロボット制御は今回初めてで確かに難しかったのですが、とっても楽しかったです!やっぱり実体のモノを動かすというのは面白いですね!

是非皆さんもtoioを買ってUnityで遊んでみてはいかがでしょうか。

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

Beytoioの中身(toio SDK for Unityでtoioをベイ化)

この記事でできること

  • toio SDK for Unity を導入した直後に、あなたのtoioがベイ化します。

どんなことをやったか

  • チュートリアルでtoioの制御について一通り遊んだ触れたあと、
  • ①時間経過で回転速度をあげてみた
    • 「cube.Move」の使用
  • ②衝突判定をつけてみた(音が鳴る)
    • 「cube.PlayPresetSound」の使用
  • ③衝突時に逆回転するようにした
    • 「OnCollision」の使用
    • 逆回転は、「cube.Move」内の引数に対して-1をかけることによって実現

実際の動画はコチラから

環境設定について

コード

  • プロジェクト内から、試行錯誤したゴミコメントを消したので、コピペして動かなかったらご連絡ください。
using UnityEngine;
using toio;

namespace toio.tutorial
{
    public class BasicScene : MonoBehaviour
    {
        float intervalTime = 0.05f;
        float elapsedTime = 0;
        Cube cube;

        int RotNum = 3;

        // 非同期初期化
        // C#標準機能であるasync/awaitキーワードを使用する事で、検索・接続それぞれで終了待ちする
        // async: 非同期キーワード
        // await: 待機キーワード
        async void Start()
        {
            // Bluetoothデバイスを検索
            var peripheral = await new NearestScanner().Scan();
            // デバイスへ接続してCube変数を生成
            cube = await new CubeConnecter().Connect(peripheral);
            // コールバック登録
            cube.collisionCallback.AddListener("BasicScene", OnCollision);
        }

        void Update()
        {
            // Cube変数の生成が完了するまで早期リターン
            if (null == cube) { return; }
            // 経過時間を計測
            elapsedTime += Time.deltaTime;

                cube.Move( (RotNum * (int)elapsedTime), (-1 * RotNum * (int)elapsedTime), 0);
        }

        void OnCollision(Cube c)
        {
            cube.PlayPresetSound(2);
            RotNum = -1 * RotNum;
        }
    }
}


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