20201216のUnityに関する記事は19件です。

Unity2020.2 で 非同期ストリーム(IAsyncEnumerable)が使いたい!

Unity 2020.2

Unity2020.2でついにC# 8.0がサポートされるようになりました。

C# 8.0にはいろいろ新機能が追加されているのですが、今回取り上げるのは「非同期ストリーム」です。

非同期ストリーム

C# 8.0の目玉機能の1つとして、「非同期ストリーム」があります。
非同期ストリームとはIEnumerableasync/await対応版である、IAsyncEnumerableとその周辺機能を指します。

非同期ストリームが使えるとたとえば次のようなことが簡単に実装できるようになります。

  • foreachの値の列挙にasync/awaitが利用できる(await foreach
  • IEnumerator時にyield returnawaitが併用できる(非同期イテレータ

RxObservableに似てますけど、用途は結構異なります。)

残念だったな!Unity2020.2は.NET Standard 2.0だ!

ではこの非同期ストリームがUnity2020.2でそのまま使えるのかというと、残念ながら使えません。

というのもIAsyncEnumerableなど、C# 8.0で利用する想定のインタフェース群は.NET Standard 2.1準拠のAPIセットとして提供されているからです。
ですがUnity 2020.2では.NET Standard 2.0準拠のAPIにしか対応していません。

そのため事実上、IAsyncEnumerableを含む非同期ストリームを素のUnity2020.2で使うことはできません。

代替としてUniTaskを使おう

非同期ストリームはとても便利なため可能であれば今すぐ使いたいです。
そこで代替として利用可能なライブラリがUniTaskです。

UniTask

UniTaskAsyncEnumerable

UniTaskではIAsyncEnumerable<T>の代替としてIUniTaskAsyncEnumerable<T>を用意してくれています。
(同様にIUniTaskAsyncEnumerator<T>もあります)

これらを用いることでC# 8.0の非同期ストリームと同等のことを実現することができます。
(さらにいうとC# 7.3でもこのUniTaskAsyncEnumerableを利用することができます)

IUniTaskAsyncEnumerableとawait foreach

UniTaskで非同期ストリームを利用する場合は、IUniTaskAsyncEnumerable<T>を用いればOKです。
Unity 2020.2でもC# 8.0await foreachは問題なく利用できるので、これと組み合わせて使いましょう。

// 非同期ストリームをIUniTaskAsyncEnumerableで実現
private async UniTask ForEachSampleAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // await foreach は Unity 2020.2 でも利用可能
    await foreach (var value in messages.WithCancellation(token))
    {
        Debug.Log(value);
        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
    }
}

UniTaskで非同期イテレータ

C# 8.0 + .NET Standard 2.1の環境では次のように書くことができます。

// 10秒間カウントアップする
// not Unity 2020.2
async IAsyncEnumerable<int> CreateStreamAsync()
{
    for (int i = 0; i < 10; i++)
    {
        yield return i;
        await Task.Delay(TimeSpan.FromSeconds(1));
    }
}

これをUnity 2020.2 + UniTaskで代替するとこうなります。

IUniTaskAsyncEnumerable<int> CreateStreamAsync()
{
    return UniTaskAsyncEnumerable.Create<int>(async (writer, token) =>
    {
        for (int i = 0; i < 10; i++)
        {
            await writer.YieldAsync(i);
            await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
        }
    });
}

イテレータが使えないので代わりにUniTaskAsyncEnumerable.Create<T>を利用してあげれば同等のことができます。

まとめ

C# 8.0の非同期イテレータを待ち望んでいた人は代替としてUniTaskを使うとよいでしょう。

詳しい資料はこちら。

おまけ

ForEachAsync, ForEachAwaitAsync, Subscribe, SubscribeAwait → await foreach

UniTaskAsyncEnumerableC# 8.0未満のC#でも利用できるようにいくつか購読用APIが用意されていました。
これらをawait foreachに書き換える場合はどうしたらいいのかを列挙します。

ForEachAsync

値を同期で消費するパターンのもの。そのまま書くだけです。
むしろForEachAsyncで書いたほうが記述量少ない。

private async UniTask ConvertedAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // ForEachAsync
    await messages.ForEachAsync(x => Debug.Log(x), token);

    // await foreach
    await foreach (var x in messages.WithCancellation(token))
    {
        Debug.Log(x);
    }
}

ForEachAwaitAsync

値を非同期でawaitしながら消費するパターンのもの。

CancellationTokenを扱う場合、パフォーマンスを考えるとForEachAwaitWithCancellationAsyncを使ったほうがよいためちょっと長ったらしくなります。
そのためCancellationTokenを使うんであればawait foreachの方が素直に書けます。

private async UniTask ConvertedAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // ForEachAwaitAsync + CancellationToken
    await messages.ForEachAwaitWithCancellationAsync(async (x, ct) =>
    {
        Debug.Log(x);
        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
    }, token);


    // await foreach
    await foreach (var x in messages.WithCancellation(token))
    {
        Debug.Log(x);
        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
    }
}

Subscribe

SubscribeIUniTaskAsyncEnumerable<T>を同期メソッドの文脈上で消費することができます(async/awaitの文脈上でなくても消費できる)。

用途がいくつかあるのでそれぞれ紹介します。

Forget目的で使うとき

値を非同期でForgetしながら消費するパターンのもの。
こちらはCancellationTokenを含んでいてもSubscribeの方が記述量は少ないです。

private async UniTask ConvertedAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // Subscribe
    messages.Subscribe(async (x, ct) =>
    {
        Debug.Log(x);
        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
    }, token);


    // await foreach
    await foreach (var x in messages.WithCancellation(token))
    {
        // ここが UniTask.Void になる
        UniTask.Void(async ct =>
        {
            Debug.Log(x);
            await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
        }, token);
    }
}

IObservable.Subscribeライクに使うとき

IObservable<T>.Subscribe()のように、async/awaitとは関係がない文脈上でIUniTaskAsyncEnumerable<T>を消費したいときに使い方です。
OnErrorOnCompletedを使いたい場合)

こっちは明らかにSubscribeで書いたほうがキレイになります。

// ↓ asyncメソッドではない!
private void ConvertedAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // Subscribe
    messages.Subscribe(
        onNext: async x =>
        {
            Debug.Log(x);
            await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token)
        },
        onCompleted: () => Debug.Log("Done"), cancellationToken: token);


    // await foreach
    {
        UniTask.Void(async token2 =>
        {
            try
            {
                await foreach (var x in messages.WithCancellation(token2))
                {
                    // ここが UniTask.Void になる
                    UniTask.Void(async ct =>
                    {
                        Debug.Log(x);
                        await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: ct);
                    }, token2);
                }

                // OnCompleted 相当
                Debug.Log("Done");
            }
            catch (Exception e) when (!(e is OperationCanceledException))
            {
                // OnError 相当
                Debug.LogError(e);
            }
        }, token);
    }
}

SubscribeAwait

SubscribeAwaitSubscribeForgetではない版です。
ForEachAwaitAsyncに挙動が近いものです(ややこしい)。

こちらもSubscribeAwaitの方が記述量は減ります。

// ↓ asyncメソッドではない!
private void ConvertedAsync(IUniTaskAsyncEnumerable<string> messages, CancellationToken token)
{
    // SubscribeAwait
    messages.SubscribeAwait(
        onNext: async x =>
        {
            Debug.Log(x);
            await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token);
        },
        onCompleted: () => Debug.Log("Done"), cancellationToken: token);


    // await foreach
    {
        UniTask.Void(async token2 =>
        {
            try
            {
                await foreach (var x in messages.WithCancellation(token2))
                {
                    Debug.Log(x);

                    // ここをちゃんとawaitし終わってからイテレートするのがSubscribeとの違い
                    await UniTask.Delay(TimeSpan.FromSeconds(1), cancellationToken: token2);
                }

                // OnCompleted 相当
                Debug.Log("Done");
            }
            catch (Exception e) when (!(e is OperationCanceledException))
            {
                // OnError 相当
                Debug.LogError(e);
            }
        }, token);
    }
}

いろいろあるけどどれを使えばいいのか

ForEachAsyncForEachAwaitAsyncSubscribeSubscribeAwait、これらは上手く使えば記述量を減らせるメリットがあります。
ですがどれもシグネチャが似ており、挙動をしっかり把握してから使わないと事故を起こすリスクがあります。
(あとクロージャを生成するので書き方によってはアロケートが発生したりする可能性あり)

一方のawait foreachは手続き的に書けるため挙動は把握しやすいですが、その分記述量が増えてしまします。
ただ挙動のわかりやすさとしてはawait foreachなので、困ったときはawait foreachで書いておけばいいでしょう。

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

Unityでポストプロセス描いてみたい

この記事は Akatsuki Advent Calendar 2020 の17日目の記事です.

はじめに

皆さんお久しぶりです.前回記事を書いてからちょうど1年が経ちます笑.
前回は Unityでシェーダー描いてみたい こちらを投稿しました.Unityでのシェーダーの書き方をわかりやすくまとめているので,ご興味がある方は是非ご覧ください!

今回はUnityにおけるポストプロセスの描き方についてまとめていきます.
前回の記事を参考に説明を進めていくので,あらかじめご覧になってから本稿を読んでいただけると幸いです.

ポストプロセスって何?

各シェーダーによってレンダリングされた最後の画像に対して,実際にディスプレイに描画する前に全体に一様なエフェクトやフィルターをかけることをポストプロセス(Post Processing)と呼びます.
業界によってはポストエフェクト(Post Effect),イメージエフェクト(Image Effect)と呼ばれたりもします.

(参考:https://www.siliconstudio.co.jp/news/pressreleases/2016/1601yebis.html)

ポストプロセスの例1
ポストプロセスの例1
ポストプロセスの例2
ポストプロセスの例2
ポストプロセスの例3
ポストプロセスの例3

上記は セバスチャン・ローブ ラリー EVO というゲーム内の1ショットで,ポストプロセスの無効/有効を比較したものです.YEBIS3 というシリコンスタジオ様が開発したポストプロセスのミドルウェアを利用したリアルタイムレンダリングです.めちゃくちゃ綺麗ですね!

・ポストプロセスの例1
元のレンダリング結果に比べて,ヘッドライト部分の発光がエフェクトとして入っています.ディスプレイは基本的に0~1の明るさを持つことができますが,1を超えるような強く発光した色を表示することはできません.したがって,内部的に1より明るい色を持たせてレンダリングし,ポストプロセスの過程で明るい色を抽出して擬似的に滲ませることで強い明るさを表現しています.この効果をブルーム(Bloom)と呼びます.
(近年では,HDR/XDRディスプレイのように強い明るさをそのままハードウェア側で処理/表現するディスプレイもあります)
また,被写体である車の後ろに広がる風景がボケてレンダリングされています.これは,カメラや人の眼球の焦点距離よりも奥(または手前)にある物体がボケて見えることを表現しています.この効果を被写界深度効果(Depth of Field)と呼びます.この効果によってリアルの体験に近い映像をレンダリングすることができ,被写体を強く主張させることもできます.

・ポストプロセスの例2
元のレンダリング結果に比べて,全体の色合いがよりオレンジ色に近く表現されています.これによって夕方の日差しの効果を受けて,風景が時間帯の雰囲気を帯びるようになります.このように,絵全体に対して任意の色合いや明るさ/暗さを適応する効果をカラーグレーディング(Color Grading),カラーコレクション(Color Correction)と呼びます.元々は映画などの映像作品に多く利用される効果です.作風にあった雰囲気や,主人公の気持ち(楽し良いときは赤色で明るく,悲しいときは青色で暗くなど)を表現することができます.
また,太陽から光の筋のようなものが見えます.これはミー散乱(光の波長>粒子の大きさ のときに生じる散乱現象)によるチンダル現象です.光のカーテン/レンブラント光線/薄明光線/光芒/God Rayなど様々な呼び名があります.この表現もポストプロセスで処理することができます.
(この効果はボリューメトリックフォグを使って表現されることもあります)

・ポストプロセスの例3
チンダル現象の他に,全体が霧がかかったように見えます.これはフォグ(Fog)による効果です.空間全体の空気感を表現することができ,奥にある物体ほど濃く霧がかかるため遠近感が強く出ます.フォグには以下のように様々な種類があり,得たい表現によって使い分けることが多いです.
・ディスタンスフォグ(Distance Fog)
  カメラから遠い物体ほど濃く霧がかかる.
・ハイトフォグ(Height Dog)
  地面に近い物体ほど濃く霧がかかる.
・ボリューメトリックフォグ(Volumetric Fog)
  光の散乱を指定空間内で演算することで生じる霧.
(ディスタンスフォグ以外はシェーダー側による工夫が大きいため,ポストプロセスのみで表現することは不可能です)

本稿で紹介するポストプロセス

上記で紹介したポストプロセスの全ての描き方を本稿で紹介しきるのは難しいので,本稿では2個ほどピックアップして紹介します.本稿を通して,Unityでポストプロセスを描くにはどういう手順が必要なのかを理解していただけると幸いです.

本稿では
・ディスタンスフォグ
・被写界深度効果
この2つを紹介します.

Unityでのポストプロセスの描き方

Unityでポストプロセスを自作する場合,主に必要な物は以下の2つです.

・スクリプト
各シェーダーがレンダリングし終わった画像を受け取り,ポストプロセスの効果を付与した画像を送る役目を持ちます.

・シェーダー
ポストプロセスもレンダリングに関わる描画のため,もちろんシェーダーで動きます.各効果の具体的な内容はシェーダーに記述します.

ディスタンスフォグの描き方

ディスタンスフォグは,物体がカメラから遠ければ遠いほど濃く霧がかかるようなポストプロセスです.
手順は簡単で,
1.カメラから各物体までの距離を格納したテクスチャ(デプステクスチャ)を取得する
2.デプステクスチャの深度値に則って霧をかける
上記のようになります.

1.何もしないポストプロセスの作成

まず以下のようなスクリプトを用意します.

DistanceFog.cs
using UnityEngine;

[ExecuteInEditMode, ImageEffectAllowedInSceneView]
public class DistanceFog : MonoBehaviour
{
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {

    }
}

上記のOnRenderImage(RenderTexture src, RenderTexture dest)は各シェーダーのレンダリングが終わった直後に呼ばれるイベント関数です.srcにはレンダリング結果が格納されており,destにポストプロセスによる処理が行われたレンダリング結果を渡します.

このスクリプトをカメラにアタッチしてみてください.Gameビューが真っ黒になると思います.
[ExecuteInEditMode]アトリビュートでプロジェクトを実行していなくても毎フレーム処理が走ります.このとき,OnRenderImage(...)が呼ばれているにもかかわらずdestに何も渡していないため,真っ黒な画面がレンダリングされてしまうわけです.

上図のようにSceneビューの設定でPost Processingsを有効にし,[ImageEffectAllowedInSceneView]アトリビュートを付けることでSceneビュー上でも同じポストプロセスの効果を得られるようになります.

次に,以下のようにスクリプトを書き換えてみてください.

DistanceFog.cs
using UnityEngine;

[ExecuteInEditMode, ImageEffectAllowedInSceneView]
public class DistanceFog : MonoBehaviour
{
    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        Graphics.Blit(src, dest);
    }
}

これでSceneビュー,Gameビューともに何事もなかったかのように描画されます.Graphics.Blit(a, b)aをbに描画するという意味です.dest = src;のように渡しても参照を渡しているだけで,destには事実上何も描画していないため意味がありません.とあるテクスチャをとあるテクスチャへ渡したい時は,このようにGraphics.Blit()を呼ぶ必要があります.

これで何もしないポストプロセスが完成しました!???

2.色を反転するポストプロセスの作成

次に,以下のようなシェーダーを追加します.

DistanceFog.shader
Shader "DistanceFog"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }

    SubShader
    {
        Cull Off
        ZTest Always
        ZWrite Off

        Tags { "RenderType"="Opaque" }

        Pass
        {
            CGPROGRAM

            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct VertexInput
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct VertexOutput
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            VertexOutput vert (VertexInput v)
            {
                VertexOutput o = (VertexOutput)0;

                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);

                return o;
            }

            fixed4 frag (VertexOutput i) : SV_Target
            {
                fixed4 finalColor = tex2D(_MainTex, i.uv);

                return finalColor;
            }

            ENDCG
        }
    }
}

また,DistanceFog.csは以下のように書き換えてください.

DistanceFog.cs
using UnityEngine;

[ExecuteInEditMode, ImageEffectAllowedInSceneView]
public class DistanceFog : MonoBehaviour
{
    [SerializeField] private Shader _distanceFogShader;

    private Material _material;

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        if (_material == null)
        {
            _material = new Material(_distanceFogShader);
        }

        Graphics.Blit(src, dest, _material);
    }
}

シェーダーの書き方や各項目については前回の Unityでシェーダー描いてみたい で紹介しているので,今回は省きます.
シェーダーに記述されているユニフォーム変数_MainTexにはポストプロセスをかける前の画像が入ってきます.このテクスチャを普通にサンプリングしてreturnしているシェーダーというわけです.
スクリプトの記述内容ですが,_materialに追加したシェーダーを適応させて,Graphics.Blit(a, b, material)の第3引数に渡しています.これによって,materialを使ってaをbに描画するという意味になります.
今回の場合は
1._materialのシェーダーの_MainTexsrcを渡す
2.シェーダーは普通に_MainTexを描画する
3.描画結果がdestに渡される
という手順になります.

これでシェーダーによる処理が付いた,何もしないポストプロセスの完成です!!!!

試しにフラグメントシェーダーの最後を以下のように書き換えてみてください.

DistanceFog.shader
// return finalColor;
return 1.0 - finalColor;

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

何もしない 反転

これで歴としたポストプロセスの完成です!やったぜ!!

3.カメラからのデプスを描画する

ディスタンスフォグを描くために必要なカメラからの深度値テクスチャを描画してみます.
スクリプトを以下のように書き換えます.

DistanceFog.cs
using UnityEngine;

[ExecuteInEditMode, ImageEffectAllowedInSceneView]
public class DistanceFog : MonoBehaviour
{
    [SerializeField] private Shader _distanceFogShader;

    private Material _material;

    private Camera _camera;

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        if (_material == null)
        {
            _material = new Material(_distanceFogShader);
        }

        if (_camera == null)
        {
            _camera = GetComponent<Camera>();
        }

        if (_camera.depthTextureMode != DepthTextureMode.Depth)
        {
            _camera.depthTextureMode = DepthTextureMode.Depth;
        }

        Graphics.Blit(src, dest, _material);
    }
}

カメラにはデプステクスチャを描くかどうかの設定 depthTextureMode があります.こちらを DepthTextureMode.Depth に変更する必要があります.

次に,フラグメントシェーダーを以下のように書き換えます.

DistanceFog.shader
// _CameraDepthTextureをユニフォーム変数として登録してください

float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
depth = Linear01Depth(depth);
return depth;

レンダリング結果は以下のようになります.

通常のレンダリング結果 デプスレンダリングの結果

_CameraDepthTexture には,手前にあるものほど黒(値が0),遠くのものほど白(値が1)といったレンダリング結果が格納されたテクスチャであることがわかります.
Linear01Depth() で変換された深度値は,カメラの nearClipfarClip で0~1で線形で分布します.
うまくグラデーションされない時はカメラの Clipping Planes を調整してみてください.

これでカメラからの深度値を取得できるようになりました!!

4.ディスタンスフォグをかける

デプステクスチャから取得した深度値を用いて,レンダリング結果に霧の色を載せればディスタンスフォグができます.フラグメントシェーダーを以下のように書き換えます.

DistanceFog.shader
// fixed4 _FogColor をユニフォーム変数へ登録

// ポストプロセス前のレンダリング結果
fixed4 finalColor = tex2D(_MainTex, i.uv);

// カメラからの深度値
float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
depth = Linear01Depth(depth);

// 深度値によるフォグの色
fixed3 fogColor = lerp(1.0, _FogColor, depth);

finalColor.rgb *= fogColor;

return finalColor;

また,スクリプト側からフォグの色を送信できるようにスクリプトを以下のように書き換えます.

DistanceFog.cs
..

public class DistanceFog : MonoBehaviour
{
    ...

    [SerializeField] private Color _fogColor;

    ...

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {

        ...

        _material.SetColor("_FogColor", _fogColor);

        Graphics.Blit(src, dest, _material);
    }
}

レンダリング結果は以下のようになります.

通常のレンダリング結果 ディスタンスフォグの結果(_FogColorは黒)

カメラから遠くに行けば行くほど, _FogColor が強く載るようにレンダリングされます. _FogColor を色々変えて試してみてください!
また,この例では _FogColor を乗算していますが, finalColor.rgb = lerp(finalColor.rgb, _FogColor, depth); のようにしても霧っぽい効果を得ることができます.

ディスタンスフォグが描けるようになりました!!ポストプロセス最高!!

5.Tips

カメラ方の深度値が格納されている _CameraDepthTexture についてですが, float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv); で得られる depth にはカメラの nearClipfarClip で0~1の範囲で値が入っています.この値は非線形であり,線形に推移している値を得るために Linear01Depth() で変換しています.
LinearEyeDepth() を利用すると,深度値をワールド空間上の距離として取得することができます.ワールド空間の絶対座標に対して処理を行いたい場合はこちらを使うと便利です.
(クリッピング空間に拠らず,ワールド空間の絶対距離で一様な処理を行えるため,ディスタンスフォグもこちらを使うとより便利です)
参考:https://light11.hatenadiary.com/entry/2018/05/08/012149

被写界深度効果の描き方

焦点が合っている被写体よりも,手前または奥にある物体がボケて見える効果を被写界深度効果と呼びます.
被写界深度効果もカメラからの深度値を利用してボケをかけていきます.本稿ではガウスフィルターを利用します.
(ガウスフィルターとは:https://w.wiki/qm7)
手順は
1.カメラから各物体までの距離を格納したテクスチャ(デプステクスチャ)を取得する
2.デプステクスチャの深度値からボケの強さを算出する
3.縦方向にガウスフィルターをかける
4.横方向にガウスフィルターをかける
上記のようになります.
今回はガウスフィルターを利用するため,ポストプロセスを2回かける必要があります.

1.ボケの強さを算出する

まずスクリプトを用意します.

DepthOfField.cs
using UnityEngine;

[ExecuteInEditMode, ImageEffectAllowedInSceneView]
public class DepthOfField : MonoBehaviour
{
    [SerializeField] private Shader _depthOfFieldShader;

    [SerializeField] private float _focusDistance;

    [SerializeField] private float _focusRange;

    [SerializeField] private float _bokehRadius;

    private Material _material;

    private Camera _camera;

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        if (_material == null)
        {
            _material = new Material(_depthOfFieldShader);
        }

        if (_camera == null)
        {
            _camera = GetComponent<Camera>();
        }

        if (_camera.depthTextureMode != DepthTextureMode.Depth)
        {
            _camera.depthTextureMode = DepthTextureMode.Depth;
        }

        _material.SetFloat("_FocusDistance", _focusDistance);
        _material.SetFloat("_FocusRange", _focusRange);
        _material.SetFloat("_BokehRadius", _bokehRadius);

        Graphics.Blit(src, dest, _material);
    }

続いてシェーダーです.

DepthOfField.shader
Shader "DepthOfField"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }

    CGINCLUDE

    #include "UnityCG.cginc"

    struct VertexInput
    {
        float4 vertex : POSITION;
        float2 uv : TEXCOORD0;
    };

    struct VertexOutput
    {
        float2 uv : TEXCOORD0;
        float4 vertex : SV_POSITION;
    };

    sampler2D _MainTex;
    float4 _MainTex_ST;
    float4 _MainTex_TexelSize;
    sampler2D _CameraDepthTexture;
    float _FocusDistance;
    float _FocusRange;
    float _BokehRadius;

    VertexOutput vert (VertexInput v)
    {
        VertexOutput o = (VertexOutput)0;

        o.vertex = UnityObjectToClipPos(v.vertex);
        o.uv = TRANSFORM_TEX(v.uv, _MainTex);

        return o;
    }

    fixed4 fragBlurVertical (VertexOutput i) : SV_Target
    {
        fixed4 finalColor = tex2D(_MainTex, i.uv);

        // カメラからの深度値(ワールド空間上の距離)
        float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
        depth = LinearEyeDepth(depth);

        // ボケの係数を算出する
        float bokehCoefficient = (depth - _FocusDistance) / _FocusRange;
        bokehCoefficient = clamp(bokehCoefficient, -1.0, 1.0);
        bokehCoefficient *= lerp(1.0, -1.0, step(bokehCoefficient, 0.0));

        return bokehCoefficient;
    }

    ENDCG

    SubShader
    {
        Cull Off
        ZTest Always
        ZWrite Off

        Tags { "RenderType"="Opaque" }

        Pass
        {
            CGPROGRAM
                #pragma vertex vert
                #pragma fragment fragBlurVertical
            ENDCG
        }
    }
}

縦方向/横方向のガウスフィルターをかけるため,Passを切り分けやすいようにちょっとだけ特殊な描き方をしています.
(Passの中身を外に出しただけです)

それでは順を追って理解していきましょう!

スクリプトに記述されている3つの変数の意味は以下の通りです.

・Focus Distance
カメラから焦点の位置までの距離です.
・Focus Range
焦点から最大ボケまでの幅です.
・Bokeh Radius
ボケの自体の幅です.

それぞれのパラメータとボケの強さの関係は下図のようになります.

ボケの強さはFocus Distanceの位置のときに0となり,Focus Rangeの値に則って増加して最大値が1になります.この計算を行っているのがフラグメントシェーダーの中身です.

float bokehCoefficient = (depth - _FocusDistance) / _FocusRange;
→ Focus Distanceの位置より奥が上図のようになり,手前はそのまま負の値となっていきます.

bokehCoefficient = clamp(bokehCoefficient, -1.0, 1.0);
→ 最小値が-1.0,最大値が1.0でクランプされます.上図でいうとFocus Distanceより手前が上下反転したような値を取ります.

bokehCoefficient *= lerp(1.0, -1.0, step(bokehCoefficient, 0.0));
→ bokehCoefficientが0.0以下の時,正負が反転するようになります.

上記によって上図のような値を取るようになります.レンダリング結果を見てみましょう.

通常のレンダリング結果 ボケの強さのレンダリング

Focus Distanceで指定した焦点が黒(値が0),手前または奥に行くに従って白(値が1)になっていると思います.

これでボケの強さを算出することができました!!

2.縦方向のガウスフィルターをかける

次に縦方向のガウスフィルターをかけてみます.フラグメントシェーダーに以下を追記してください.

DepthOfField.shader
fixed4 fragBlurVertical (VertexOutput i) : SV_Target
{
    fixed4 finalColor = tex2D(_MainTex, i.uv);

    // カメラからの深度値(ワールド空間上の距離)
    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
    depth = LinearEyeDepth(depth);

    // ボケの係数を算出する
    float bokehCoefficient = (depth - _FocusDistance) / _FocusRange;
    bokehCoefficient = clamp(bokehCoefficient, -1, 1);
    bokehCoefficient *= lerp(1, -1, step(bokehCoefficient, 0));

    // ボケをかける
    fixed3 bokehColor = 0;
    bokehColor += tex2D(_MainTex, i.uv + float2(0.0,  3.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor += tex2D(_MainTex, i.uv + float2(0.0,  2.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor += tex2D(_MainTex, i.uv + float2(0.0,  1.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor += finalColor.rgb;
    bokehColor += tex2D(_MainTex, i.uv + float2(0.0, -1.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor += tex2D(_MainTex, i.uv + float2(0.0, -2.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor += tex2D(_MainTex, i.uv + float2(0.0, -3.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor *= 0.142857;

    finalColor.rgb = lerp(finalColor, bokehColor, bokehCoefficient);

    return finalColor;
}

大量にテクスチャサンプリングをしていますね!
上記からわかるように,ボケをかけるフィルター類は処理負荷が高いため,注意が必要です.

サンプリングする位置をずらしながら加算しています.ガウスフィルターの特徴ですね!

上図のように7点でサンプリングをしていきます.
(float2(0, 0) の場合はフラグメントシェーダーの最初で宣言した finalColor と同じなのでこちらを代用しています)
サンプリングする座標のオフセットである float2(x, y) ですが,以下の3つの係数がかけられています.

_MainTex_TexelSize.xy
→ こちらはレンダリング結果のテクセルのサイズが格納されています.uv座標は解像度に拠らず0~1の値しか持たないため,テクセルのサイズを乗算して,現在の解像度の座標を参照できるようにしています.

bokehCoefficient
→ 算出したボケの強さです.こちらをオフセットにかけることによって,どれだけ周囲の色の影響を受けるのかが変わってきます.ボケの広がりに影響させているイメージです.

_BokehRadius
→ こちらも bokehCoefficient と同じようにボケの広がりの強さです.ガウスフィルターではボケの広がりはサンプリングする回数を増やしていくしかないため,どんどん処理負荷が膨れ上がっていきます. _BokehRadius が1.0のときは通常のガウスフィルターと同じになりますが,値を上げていけばボケの広がりをブーストできるようになります.上げすぎると絵が劣化してしまうので注意です.

7点のサンプリング結果を全て加算しているので bokehColor *= 0.142857; このように1/7を乗算し,ボケた色を得ることができました.
最後は
finalColor.rgb = lerp(finalColor, bokehColor, bokehCoefficient);
上記のように bokehCoefficient (ボケの強さ)を引数にラープした結果を返します.

通常のレンダリング結果 焦点位置とボケの強さ 縦方向にボカしたレンダリング結果

レンダリング結果は上記のようになります.
縦方向へのガウスフィルターしか効いていませんが,うまく動いていそうですね!

次は横方向のガウスフィルターだ!!!!

3.横方向のガウスフィルターをかける

横方向にかけるガウスフィルターも,縦方向にかけるガウスフィルターとほとんど同じです.
まずはスクリプトから見ていきます.

DepthOfField.cs
...

public class DepthOfField : MonoBehaviour
{
    ...

    private RenderTexture _tempTexture;

    private void OnRenderImage(RenderTexture src, RenderTexture dest)
    {
        ...

        if (_tempTexture == null)
        {
            _tempTexture = RenderTexture.GetTemporary(_camera.pixelWidth, _camera.pixelHeight, 0, RenderTextureFormat.Default);
        }
        else if (_tempTexture.width != _camera.pixelWidth || _tempTexture.height != _camera.pixelHeight)
        {
            RenderTexture.ReleaseTemporary(_tempTexture);
            _tempTexture = RenderTexture.GetTemporary(_camera.pixelWidth, _camera.pixelHeight, 0, RenderTextureFormat.Default);
        }

        _material.SetFloat("_FocusDistance", _FocusDistance);
        _material.SetFloat("_FocusRange", _FocusRange);
        _material.SetFloat("_BokehRadius", _BokehRadius);

        Graphics.Blit(src, _tempTexture, _material, 0);
        Graphics.Blit(_tempTexture, dest, _material, 1);
    }
}

はじめに述べたように,ガウスフィルターは縦横で2回かける必要があります.そこで,
・1回目の描画(縦方向のガウスフィルター)は一時的なテクスチャへ描画する
・2回目の描画(横方向のガウスフィルター)で最終的なレンダリング結果として返す
という手順を取ります.
_tempTexture は1回目の描画結果を格納する一時的なテクスチャです.RenderTextureRenderTexture.GetTemporary() で初期化することができます.また,解放する時は RenderTexture.Release() を利用します.
上記のスクリプトはカメラの解像度がリアルタイムで変更される可能性を加味して, _tempTexture の解像度がカメラの解像度と異なる時はもう一度初期化するようにしています.

Graphics.Blit() の4つ目の引数は,シェーダーパスの何番目を使うのか指定しています.
src_tempTexture_material のシェーダーパス0番(縦方向のガウスフィルター)で描画する
_tempTexturedest_material のシェーダーパス1番(横方向のガウスフィルター)で描画する
ということになります.

次にシェーダーを見ていきます.

DepthOfField.shader
...

fixed4 fragBlurHorizontal (VertexOutput i) : SV_Target
{
    fixed4 finalColor = tex2D(_MainTex, i.uv);

    // カメラからの深度値(ワールド空間上の距離)
    float depth = SAMPLE_DEPTH_TEXTURE(_CameraDepthTexture, i.uv);
    depth = LinearEyeDepth(depth);

    // ボケの係数を算出する
    float bokehCoefficient = (depth - _FocusDistance) / _FocusRange;
    bokehCoefficient = clamp(bokehCoefficient, -1, 1);
    bokehCoefficient *= lerp(1, -1, step(bokehCoefficient, 0));

    // ボケをかける
    fixed3 bokehColor = 0;
    bokehColor += tex2D(_MainTex, i.uv + float2( 3.0, 0.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor += tex2D(_MainTex, i.uv + float2( 2.0, 0.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor += tex2D(_MainTex, i.uv + float2( 1.0, 0.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor += finalColor.rgb;
    bokehColor += tex2D(_MainTex, i.uv + float2(-1.0, 0.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor += tex2D(_MainTex, i.uv + float2(-2.0, 0.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor += tex2D(_MainTex, i.uv + float2(-3.0, 0.0) * _MainTex_TexelSize.xy * bokehCoefficient * _BokehRadius);
    bokehColor *= 0.142857;

    finalColor.rgb = lerp(finalColor, bokehColor, bokehCoefficient);

    return finalColor;
}

ENDCG

SubShader
{
    Cull Off
    ZTest Always
    ZWrite Off

    Tags { "RenderType"="Opaque" }

    Pass
    {
        CGPROGRAM
            #pragma vertex vert
            #pragma fragment fragBlurVertical
        ENDCG
    }

    Pass
    {
        CGPROGRAM
            #pragma vertex vert
            #pragma fragment fragBlurHorizontal
        ENDCG
    }
}

パスが1つ追加され,その中身(フラグメントシェーダー)は fragBlurVertical とほとんど同じですね!
このパスでは横方向へガウスフィルターをかけるので,テクスチャサンプリングのオフセット座標がxとyで入れ替わっています.

上記を適応したレンダリング結果を見てみましょう.

通常のレンダリング結果 焦点位置とボケの強さ 被写界深度効果が載ったレンダリング結果

被写界深度効果が得られました!お疲れ様でした!???

4.Tips

ボケの広がりをガウスフィルターのみで広げて行こうとすると,解像度が高ければ高いほどサンプリングの回数が爆発的に増えてしまいます.本稿のように,一定値を乗算して無理やり広げるやり方が1番簡単な手法ですが,ガウスフィルターとは異なるボケフィルターを活用してみても面白いですよ!
また,ダウンサンプリング/アップサンプリングという手法を使えばより広範囲のボケを比較的軽量に作成することも可能なので,チャレンジしてみてください!

まとめ

本稿ではポストプロセスについて簡単に紹介してきました.
今回は
・ディスタンスフォグ
・被写界深度効果
の2つをピックアップしました.この他にも無数のポストプロセスの手法があるので,ご興味がある方は是非描いてみてください!

さいごに

ここまで読んでいただき,ありがとうございます!
Unityでポストプロセスをどうやって描くのか,ご理解いただけたら幸いです!

色々なポストプロセスを知って,描けるようになりましょう!

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

UnityとFirebaseでアプリ開発する際にユーザーデータの取り扱い方まとめ

こちらは Firebase Advent Calendar 2020 の 16日目の記事です。

概要

本記事は、Unity #2 Advent Calendar 2020 の 8日目の記事(前半)Unity 超初心者が Firebase でアプリ開発する際に必要になるスキル の後半内容となります。
そのため、全て内容を引き継いで解説を進めるため、本記事を読む前に前半をご覧くださいmm

ふりかえり

前半では、iOS/Android アプリを開発する際にユーザーデータを Firebase Realtime Database (Firebase Database) で保存取得しましょうと話し、その際にセキュリティ面を考えて rule でユーザー毎でアクセスできる設定のために Firebase Authentication (Firebase Auth) を使うことを解説しましたが、実装に関する解説をこれから解説していきます。

Firebase Auth

前半で 認証方法をメアド/パスワードで取り扱う 解説をしました。
もう少し詳しく話すと、例えばユーザーデータを取り扱っているようなカジュアルゲームでニックネームなどは設定するもののメアドやパスワードは設定しないと思います。全てのアプリがそうではないですが、あの裏側ではよしなにユーザー情報が作成されており、ユーザー ↔︎ アプリを勝手に紐づけています。

この方法でFirebase Authのメアド/パスワードで実装するとしたら、具体的に次の内容を考えてました。

  1. @前半をUUID/UDIDなどを用いて@後半はFirebaseが勝手に作っているドメインを扱ってメアド生成メソッドを作る
  2. 英数字記号をランダムで作ってくれるメアド生成メソッドを作る
  3. 2と3をCreateUserWithEmailAndPasswordAsyncに渡してアカウント生成
  4. FirebaseAuth.DefaultInstance.CurrentUserでユーザー情報が取得できたらログイン中で取得できない場合は自動ログイン
  5. 自動ログインの際にSignInWithEmailAndPasswordAsyncのために2と3を PlayerPrefs で保存取得できるようにしておく

ですが、匿名認証 を使うことで1~3を飛ばして4と5を実現することができます。

 

アプリの要件にもよりますが、アプリ初起動時に裏側で勝手に匿名認証のユーザー作成を行い、ユーザー設定のようなメニューを設けて「アプリ引き継ぎ設定」を作ることで上記1~3はやらなくてよくなります。
このように一般的なWeb/Mobileのアプリ開発とは異なり、ゲームやコンテンツのアプリはどれだけユーザーに面白い・楽しいと思ってもらいとにかく面倒な要素を取っ払うか重要になります。

FirebaseAuth.SignInAnonymouslyAsync

早速、匿名証明でユーザー作成をやってみましょう。
公式のサンプルコードを参考にし、delegateを用いてFirebaseAuthだけを管理するクラスで以下のように実装するとよいでしょう。

FirebaseAuthManager.cs
using UnityEngine;
using Firebase.Auth;

public class FirebaseAuthManager : MonoBehaviour
{
    FirebaseAuth _auth;
    FirebaseUser _user;
    public FirebaseUser UserData { get { return _user; } }
    public delegate void CreateUser(bool result);

    void Awake()
    {
        // 初期化
        _auth = FirebaseAuth.DefaultInstance;
        // すでにユーザーが作られているのか確認
        if (_auth.CurrentUser.UserId == null)
        {
            // まだユーザーができていないためユーザー作成
            Create((result) =>
            {
                if (result)
                {
                    Debug.Log($"成功: #{_user.UserId}");
                }
                else
                {
                    Debug.Log("失敗");
                }
            });
        }
        else
        {
            _user = _auth.CurrentUser;
            Debug.Log($"ログイン中: #{_user.UserId}");
        }
    }

    /// <summary>
    /// 匿名でユーザー作成
    /// </summary>
    public void Create(CreateUser callback)
    {
        _auth.SignInAnonymouslyAsync().ContinueWith(task => {
            if (task.IsCanceled)
            {
                Debug.LogError("SignInAnonymouslyAsync was canceled.");
                callback(false);
                return;
            }
            if (task.IsFaulted)
            {
                Debug.LogError("SignInAnonymouslyAsync encountered an error: " + task.Exception);
                callback(false);
                return;
            }

            _user = task.Result;
            Debug.Log($"User signed in successfully: {_user.DisplayName} ({_user.UserId})");
            callback(true);
        });
    }
}

 
delegateは、上記のようにdelegateの型に合うメソッドを用意すれば他クラスにメソッドの引数として渡すことができ、その他クラスのメソッドの処理終わりのタイミングで渡したメソッドが呼び出される仕組みとなります。++C++; // 未確認飛行 C さんが解説されている記事 がとてもわかりやすいので、こちらを参考にされてくださいmm

「匿名アカウントを永久アカウントに変換する」

先ほども解説した通り、最初に匿名でユーザーを作り、後からメアド/パスワードもしくはSNS連携に切り替えることができます。
方法としては、サンプルコードで解説されている通りFirebaseAuth.DefaultInstance.CurrentUser.LinkWithCredentialAsyncを扱うようです。

FirebaseAuthManager.cs
    /// <summary>
    /// メアド/パスワードでユーザー作成
    /// </summary>
    public void Create(string email, string password, CreateUser callback)
    {
        // すでにユーザーが作られているのか確認
        if (_auth.CurrentUser.UserId == null)
        {
            // 新規でユーザー作成
            _auth.CreateUserWithEmailAndPasswordAsync(email, password).ContinueWith(task =>
            {
                if (task.IsCanceled)
                {
                    Debug.LogError("CreateUserWithEmailAndPasswordAsync was canceled.");
                    callback(false);
                    return;
                }
                if (task.IsFaulted)
                {
                    Debug.LogError("CreateUserWithEmailAndPasswordAsync encountered an error: " + task.Exception);
                    callback(false);
                    return;
                }

                _user = task.Result;
                Debug.Log($"Firebase user created successfully: {_user.DisplayName} ({_user.UserId})");

                callback(true);
            });
        }
        else
        {
            // 認証方法追加
            Credential credential = EmailAuthProvider.GetCredential(email, password);
            _auth.CurrentUser.LinkWithCredentialAsync(credential).ContinueWith(task => {
                if (task.IsCanceled)
                {
                    Debug.LogError("LinkWithCredentialAsync was canceled.");
                    callback(false);
                    return;
                }
                if (task.IsFaulted)
                {
                    Debug.LogError("LinkWithCredentialAsync encountered an error: " + task.Exception);
                    callback(false);
                    return;
                }

                _user = task.Result;
                Debug.Log($"Credentials successfully linked to Firebase user: {_user.DisplayName} ({_user.UserId})");

                callback(true);
            });
        }
    }

 
これでAuthの対応は一旦終わりで、目的のDatabaseの実装に進んでいきます。

Firebase Database

最後にDatabaseでは、「Authで作ったユーザー情報をもとにruleの設定」「データの保存と取得」の2点を解説して終わりとなります。
Authでユーザー情報を扱えるようになり、ruleでAuthのユーザーだけDatabaseとやりとりできるようになりました。

ruleの設定

設定はとても簡単で、公式にユーザー認証した際のルール設定方法の内容をそのまま以下のように設定すれば大丈夫です。

 

databaseを初めて使うときにテストモードにしていると上記スクショのようにコメントアウトします。

また、今回はサンプルとしてusersの下にデータを保存と取得する内容でまとめていますが、他にもランキング情報やチャット情報を取り扱う場合は、rankingschatsなどのように別keyになると思います。
ポイントは、$uidが先ほどのFirebaseAuth.CurrentUser.UserIdと同じものでなければ許可しないように設定することです。

ユーザーデータの保存と取得

Startメソッドに保存と取得の処理を書いてますが、本来は別クラスから処理を呼び出すことになります。例として、ゲームのクリアタイムをニックネームとともに保存するサンプルを用意してみました。

FirebaseDatabaseManager.cs
using UnityEngine;
using Firebase.Database;

public class UserPlayData
{
    public string username;
    public float time;

    public UserPlayData(string username, float time)
    {
        this.username = username;
        this.time = time;
    }
}

public class FirebaseDatabaseManager : MonoBehaviour
{
    readonly string USER_DATA_KEY = "users";
    DatabaseReference reference;
    [SerializeField]
    FirebaseAuthSample _auth;
    public delegate void GetUserDataCallback(UserPlayData result);

    void Start()
    {
        reference = FirebaseDatabase.DefaultInstance.RootReference;

        // サンプル: 保存
        var userData = new UserPlayData("gremito", 10.5f);
        SaveUserData(userData);

        // サンプル: 取得
        GetUserData((result) => {
            if(result == null)
            {
                Debug.LogWarning("失敗");
            }
            else
            {
                Debug.Log($"username: {result.username}");
                Debug.Log($"time: {result.time}");
            }
        });
    }

    /// <summary>
    /// ユーザーデータをJson化してdatabaseに保存(SetRawJsonValueAsync)
    /// </summary>
    public void SaveUserData(UserPlayData data)
    {
        // 公式サンプル方法: https://firebase.google.com/docs/database/unity/save-data?authuser=0#write_update_or_delete_data_at_a_reference
        var json = JsonUtility.ToJson(data);
        reference.Child(USER_DATA_KEY).Child(_auth.UserData.UserId).SetRawJsonValueAsync(json);
    }

    /// <summary>
    /// ユーザーデータを取得
    /// </summary>
    public void GetUserData(GetUserDataCallback callback)
    {
        FirebaseDatabase.DefaultInstance.GetReference(USER_DATA_KEY)
            .Child(_auth.UserData.UserId).GetValueAsync().ContinueWith(task =>
            {
                if (task.IsFaulted)
                    callback(null);

                else if (task.IsCompleted)
                    callback(new UserPlayData(
                        task.Result.Child("username").Value.ToString(),
                        float.Parse(task.Result.Child("time").Value.ToString())));
            });
    }
}

  

右スクショのFirebaseコンソールのDatabaseで保存したデータが反映されているようにUnityのデータを保存取得することができました。
これは、最低限の実装なのでAppStore/GooglePlayのストアにアプリをリリースする品質を考えるとトランザクションイベント並べ替えとフィルタリングなども上手く扱う必要が出てくるかもしれません。

オフライン機能

Realtime Databaseにはオフライン機能があり、最後に通信した最新のデータをFriebase SDK側でデータを保持して取り扱うことができます。

アプリの要件に応じて最新情報が必須な場面は、意図的にアプリを使用できなくする機能実装が必要になります。
反対に最新情報が必要でなくてもアプリを操作できる場面は、特に対応しなくていいかもしれません。

例えば、カジュアルゲームのアプリでランキング機能の場合、理想は最新情報が欲しいけど別に後から同期させて最新のランキング情報にすればいいから、オフラインでもゲームを楽しめるケースが考えられます。

まとめ

これでFirebaseのAuthとDatabaseを組み合わせて、しっかりセキュリティを担保したユーザーデータの取り扱い方を知れたと思います。

先ほども解説した通り、ここまでの内容は最低限のサンプルでFirebaseには他にも機能が豊富で、アプリの要件に応じてここで解説していない使える機能があると思います。

これをきっかけにさらにFirebaseを知ることでサーバーサイドの知識も得られますし、一石二鳥以上のスキルアップになると思うのでぜひ活用していきましょう!

 

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

外部スクリプトを参照してスコアを加算する方法

この記事でできること

  • 生成&消滅するオブジェクトに依存することなく、外部スクリプトの関数の使用によりスコアを加算することができる。

ダウンロード (1).gif

つくりかた(準備編)

サンプルSceneを準備する

プレファブで弾を発射する

  • 銃口(Muzzle)から弾(bullet)のクローンが発射される
Muzzle.cs
using UnityEngine;

public class Muzzle : MonoBehaviour
{
    public GameObject bullet;

    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            Shot();
        }
    }

    void Shot()
    {
        GameObject obj;
        obj = GameObject.Instantiate(bullet);
        obj.transform.position = transform.position;
        obj.GetComponent<Rigidbody>().AddForce(transform.forward * 1000);
    }
}

プレファブで敵を生成する

  • 適当なオブジェクトにアタッチすればOK。
EnemyGenerator.cs
using UnityEngine;

public class EnemyGenerator : MonoBehaviour
{
    public GameObject Enemy;
    private float x;
    private float y;
    private float z;

    void Start()
    {
        InvokeRepeating("Generate", 1, 1);
    }

    void Generate()
    {
        x = Random.Range(-5, 5);
        y = 1;
        z = Random.Range(-5, 5);
        Instantiate(Enemy, new Vector3(x, y, z), Quaternion.identity);
    }
}

つくりかた(本題編)

適当なオブジェクトに、スコア加算のスクリプトをアタッチする

  • スコア表示用のテキストラベルにアタッチするのが分かりやすいかもしれませんね。
ScoreCount.cs
using UnityEngine;
using UnityEngine.UI;

public class ScoreCount : MonoBehaviour
{
    private int intScore = 0;   //スコア加算用変数
    public Text ScoreText;      //画面に表示する文字をpublic変数とする

    public void ScoreAdd()
    {
        intScore++;
        ScoreText.text = intScore.ToString();
    }
}

たとえば弾に上記のスコア加算を呼び出すスクリプトを書くとこうなる

  • 下記だと弾に書いたけれども、敵に書いてもよい。
Bullet.cs
using UnityEngine;

public class Bullet : MonoBehaviour
{
    ScoreCount SCScriot;

    void Start()
    {
        //今回はCanvas内にscoreというテキストラベルのオブジェクトを作成して、
        //そこにスコア加算のスクリプトをアタッチしているので、
        //GameObject.Findで"Score"を探している。
        SCScriot = GameObject.Find("Score").GetComponent<ScoreCount>();
    }

    void OnCollisionEnter(Collision col)
    {
        if (col.gameObject.tag == "enemy")
        {
            SCScriot.ScoreAdd(); //←ここがスコア加算の呼び出し箇所。弾が敵に当たったら、スコア加算するよ、ということ。
            Destroy(gameObject);
        }
    }
}
  • スコア加算されたかな?
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

WindowsのUnityからBLEアプリ経由してtoioで遊ぶ

ToiQ

「ロボットやろうぜ! - toio & Unity 作品動画コンテスト -」で、ToiQという作品を作ってみました。

全体像は↓にあるので、よければどうぞ。
https://www.youtube.com/watch?v=eh4HUkaFCJI

作品の経緯

もともとこういうのを作って遊んでいました。
今回のコンテストを知って、これは何かやらねばと思い立ちました。

コンテストに向けて以下のことを意識して何を作るかを考えてました。

  • toioという実物を活かす
  • Unityの得意な映像表現を活かす
  • リアルとバーチャルをつなぐ

toioを使ってバーチャルコースをコントロールするだけでは面白みが足りない気がしたので、以下のような操作手法にしました。

  • toioをチョロQのように引っ張ってリアルボードを走行する
  • toioを手に持ってバーチャルコースの左右を操作
  • toioを引っ張ってちょうどよくブレーキ
  • toioは操作の入力装置として使うだけでなく、自動で動くこともある

最終的に、リアルのtoioが画面に向かって走り、その先にバーチャルなコースが繋がっている という体験を目指すことにしました。

WindowsのBLE

toioはBluetooth LEで制御できます。
ToiQは最終的にこういう繋がりになってますが、当初は試行錯誤しました。
toioBleUnity.png

toio SDK for Unity(見送り)

コンテストのきっかけとなったtoioとUnityをつなぐSDKです。
ただ、残念ながらWindowsではシミュレータまでで、実物toioには連動しないので見送りました。
https://morikatron.com/t4u/

toio.js(見送り)

公式に toio.js というjavascript SDKがあります。
ただ、Windowsの場合は、導入に手がかかりそうなのとBluetooth 4.0 USB adapter、USBのBLEドングルが別途に必要になるらしく、WindowsノートPC搭載のBLEをそのまま使いたかったのでtoio.jsの導入は見送りました。
参考記事:toioのjavascriptライブラリが公開されたのでexample動かしてみた

Unityから直接WindowsのBLEを扱う(断念)

当初採用予定だった方法です。

UnityはC#で書けるし、C#でWindowsのBLEを扱っている前例も普通にみつかるし、いけるだろ、と思ったらダメでした。
Unity Editor、スタンドアローンモード書き出しはBLEなどの機能が含むWinRTと呼ばれるWindows APIにアクセスできないようでした。
スタンドアローンではなく、UWP用のビルドをすることでWinRTが扱えそうですが、自分の環境ではスムーズにうまくいかなかったのと、BLEが使いたいだけでそれ以外はUWPである必要が無いので、Unityから直接BLEを扱うのは断念。

C#のWindowsコンソールアプリを作って中継する(採用)

別途、中継用のアプリを用意することにしました。

タスクトレイ常駐アプリの方が目立たなくて良いと思いますが、今回はログを見ながら進めたかったのでコンソールアプリにしました。

ただ、WindowsのBLEはWinRTの方に(なぜか)属していて、UWPアプリだとBLEが普通に使えますが、通常はコンソールアプリではWinRTのBLE機能は使えません。
でも、UWPでなくてもWinRTを使えるようにする方法がいくつかあります。

参考記事:WPFアプリ(.Net Framework)でUWPのAPIを使う

VisualStudioの NuGetパッケージの管理からMicrosoft.Windows.SDK.Contractsを追加するのが手っ取り早いかと思います。
vs_cap_1.png
vs_cap_2.png

これでusing Windows.Devices.Bluetooth;などが使えるようになります。

なお、有料ですがUnityからBLEを扱えるようにするAssetもあるようです。
参考記事:toio for Unity Editor Windows BLE対応
このAssetは試してないですが、BLEのサーバーと通信するとのことなので、今回自分が取った手法に近いような気がします。

BLEの中継

中継アプリは今回専用の仕組みは持たず、中継に徹するようにしました。
具体的なtoio操作のBLEコマンドはUnity側で管理してUDPで中継アプリを経由して送るようにしてます。

こういう仕組みにしたのは以下の狙いがあります。

  • 今回以外のtoio連携も見越して中継アプリの汎用性を上げる
  • 製作中は中継アプリを立ち上げっぱなしにすればよく、何かの更新作業の度にUnityも中継アプリも両方起動しなおすようなことを避ける

例:Unityから中継アプリにコマンドを投げる

Unity側からtoioを動かす場合の抜粋
//Move(30,30)という感じでどこかから呼び出す

//toio移動用の関数
void Move(int leftMotor, int rightMotor) {
    //左モーターの回転方向
    byte leftMotorVector = 0x01;
    if (leftMotor < 0) {
        leftMotorVector = 0x02;
    }
    //右モーターの回転方向
    byte rightMotorVector = 0x01;
    if (rightMotor < 0) {
        rightMotorVector = 0x02;
    }

    //toioを移動させるBLEコマンド
    byte[] dataValue = { 0x01, 0x01, leftMotorVector, Convert.ToByte(Mathf.Abs(leftMotor)), 0x02, rightMotorVector, Convert.ToByte(Mathf.Abs(rightMotor)) };

    //中継アプリで処理を分岐するための追加コマンドを足す
    byte[] result = GetSendTargetData(0,(int)ToioActionType.MOVE,dataValue);

    //中継アプリにUDP送信する。SendUDPの中身は省略
    SendUDP(result);
}

//中継アプリ用の追加コマンドを取得する。deviceNoはtoioを複数台使う時用
byte[] GetSendTargetData(int deviceNo, int actionNo, byte[] dataValue) {
    byte[] baseValue = { Convert.ToByte(deviceNo), Convert.ToByte(actionNo) };
    return Enumerable.Concat(baseValue, dataValue).ToArray();
}

//中継アプリがBLEのどのCharacteristicsを使うかを振り分けるためのenum
public enum ToioActionType
{
    SETTING,
    MOVE,
    SOUND,
    LIGHT
}

例:中継アプリがUnityからコマンドを受け取ってtoioとBLEで連携する

中継アプリがUnity側からからのUDPを受信したあとの抜粋
//toioデバイス管理用の独自Class。BluetoothLEDeviceをラップしている。具体的な中身は下部参照
//今回はtoio1個だけなのでToioDeviceも1個。
ToioDevice toio;

//別の個所でnew したUdpClientを別ThreadからOnUdpRecieveを呼んでデータ受信
void OnUdpRecieve() {
    while (isActive) {
        IPEndPoint remoteEP = new IPEndPoint(IPAddress.Any, 10001);
        byte[] data = udpClient.Receive(ref remoteEP);
        SendDataToToio(data);
    }
}


//UnityからUDP経由で受け取ったデータを読み取って処理分岐
void SendDataToToio(byte[] data) {
    //Unityからの追加コマンドその1。toio番号を読み取る
    byte toioNo = data[0];

    //Unityからの追加コマンドその2。toioのアクション番号を読み取る。どのCharacteristicsを使うか判別する時に使う
    byte actionNo = data[1];

    //Characteristicsに渡すコマンドだけを切り取る
    int dataLength = data.Length - 2;
    byte[] buffer = new byte[dataLength];
    Array.Copy(data, 2, buffer, 0, dataLength);

    //ToioDeviceにBLEコマンドを送る。ToioActionTypeはUnityの同enumと同じ中身
    switch (actionNo) {
        case (int)ToioActionType.SETTING:
            toio.SetDeviceSetting(buffer);
            break;
        case (int)ToioActionType.MOVE: 
            toio.SetMove(buffer);
            break;
        case (int)ToioActionType.SOUND:
            toio.SetSound(buffer);
            break;
        case (int)ToioActionType.LIGHT:
            toio.SetLight(buffer);
            break;
    }
}

中継アプリで作ったToioDeviceクラスの中身も一部載せておきます。

↓クリックで開きます。

ToioDeviceクラスの中身抜粋
中継アプリで使ったToioDeviceクラスの中身抜粋
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Devices.Bluetooth;
using Windows.Devices.Bluetooth.GenericAttributeProfile;
using Windows.Storage.Streams;

class ToioDevice
{

    //toio本体のUUID
    static readonly public string uuid = "10B20100-5B3B-4571-9508-CF3EFCD7BBAE";

    //各機能のCharacteristic UUID
    readonly string light_characteristicUUID = "10B20103-5B3B-4571-9508-CF3EFCD7BBAE";
    readonly string motor_characteristicUUID = "10B20102-5B3B-4571-9508-CF3EFCD7BBAE";
    readonly string scan_characteristicUUID = "10B20101-5B3B-4571-9508-CF3EFCD7BBAE";
    readonly string motion_characteristicUUID = "10B20106-5B3B-4571-9508-CF3EFCD7BBAE";
    readonly string setting_characteristicUUID = "10B201FF-5B3B-4571-9508-CF3EFCD7BBAE";
    readonly string sound_characteristicUUID = "10B20104-5B3B-4571-9508-CF3EFCD7BBAE";
    readonly string magnetic_characteristicUUID = "10B20106-5B3B-4571-9508-CF3EFCD7BBAE";
    readonly string functionalBtn_characteristicUUID = "10B20107-5B3B-4571-9508-CF3EFCD7BBAE";

  //BLEDevice
    BluetoothLEDevice device;

    //各機能のCharacteristic
    GattCharacteristic lightCharacteristic;
    GattCharacteristic motorCharacteristic;
    GattCharacteristic scanCharacteristic;
    GattCharacteristic motionCharacteristic;
    GattCharacteristic settingCharacteristic;
    GattCharacteristic soundCharacteristic;
    GattCharacteristic magneticCharacteristic;
    GattCharacteristic functionalBtnCharacteristic;

    //データ読み取り用のdelegate
    public delegate void ValueChangeCallback(byte[] data);

    //データ読み取り時のcallback
    public System.Action OnDeviceReadyCallback;
    public ValueChangeCallback OnScanValueChangeCallback;
    public ValueChangeCallback OnMotionValueChangeCallback;
    public ValueChangeCallback OnMagneticValueChangeCallback;
    public ValueChangeCallback OnFunctionalBtnValueChangeCallback;

    //外のClassからBluetoothLEDeviceを渡してnew ToioDevice()します
    public ToioDevice(BluetoothLEDevice d) {
        device = d;
        device.ConnectionStatusChanged += OnConnectionStatusChanged;
    }

    //toioに接続
    public async void Connect() {
        //toioのBLEのserviceを取得
        GattDeviceServicesResult serviceResult = await device.GetGattServicesForUuidAsync(new Guid(uuid));

        //toioのそれぞれのCharacteristicを取得
        lightCharacteristic = await GetCharacteristic(serviceResult, light_characteristicUUID);
        motorCharacteristic = await GetCharacteristic(serviceResult, motor_characteristicUUID);
        scanCharacteristic = await GetCharacteristic(serviceResult, scan_characteristicUUID);
        motionCharacteristic = await GetCharacteristic(serviceResult, motion_characteristicUUID);
        settingCharacteristic = await GetCharacteristic(serviceResult, setting_characteristicUUID);
        soundCharacteristic = await GetCharacteristic(serviceResult,sound_characteristicUUID);
        magneticCharacteristic = await GetCharacteristic(serviceResult, magnetic_characteristicUUID);
        functionalBtnCharacteristic = await GetCharacteristic(serviceResult, functionalBtn_characteristicUUID);

        //toioからBLEのnotifyを受け取るための準備
        GattCommunicationStatus scanStatus = await scanCharacteristic.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue.Notify);
        if (scanStatus == GattCommunicationStatus.Success) {
            scanCharacteristic.ValueChanged += OnScanCharacteristic_ValueChanged;
        }

        GattCommunicationStatus motionStatus = await motionCharacteristic.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue.Notify);
        if (motionStatus == GattCommunicationStatus.Success) {
            motionCharacteristic.ValueChanged += OnMotionCharacteristic_ValueChanged;
        }

        GattCommunicationStatus magneticStatus = await magneticCharacteristic.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue.Notify);
        if (magneticStatus == GattCommunicationStatus.Success)
        {
            magneticCharacteristic.ValueChanged += OnMagnetic_ValueChanged;
        }

        GattCommunicationStatus functionalBtnStatus = await functionalBtnCharacteristic.WriteClientCharacteristicConfigurationDescriptorAsync(GattClientCharacteristicConfigurationDescriptorValue.Notify);
        if (functionalBtnStatus == GattCommunicationStatus.Success)
        {
            functionalBtnCharacteristic.ValueChanged += OnFunctionalBtn_ValueChanged;
        }

        OnDeviceReadyCallback();

    }

    //BLEの各Characteristicを取得する処理を関数にして使い回す
    async Task<GattCharacteristic> GetCharacteristic(GattDeviceServicesResult serviceResult,string uuidStr)
    {
        GattCharacteristicsResult characteristics = await serviceResult.Services.First().GetCharacteristicsForUuidAsync(new Guid(uuidStr));
        return characteristics.Characteristics.First();
    }

    //移動用のCharacteristicにBLEコマンドを渡す
    public async void SetMove(byte[] value) {
        await motorCharacteristic.WriteValueAsync(value.AsBuffer());
    }

    //ライト操作用のCharacteristicにBLEコマンドを渡す
    public async void SetLight(byte[] value)
    {
        await lightCharacteristic.WriteValueAsync(value.AsBuffer());
    }

    //読み取りセンサーのnotifyを受け取ったら外のclassにcallbackする
    private void OnScanCharacteristic_ValueChanged(GattCharacteristic sender, GattValueChangedEventArgs args) {
        IBuffer buffer = args.CharacteristicValue;
        byte[] readBytes = new byte[buffer.Length];
        using (DataReader reader = DataReader.FromBuffer(buffer)) {
            reader.ReadBytes(readBytes);
            OnScanValueChangeCallback(readBytes);
        }
    }

    //一部関数省略

}

中継アプリもUnityに合わせてC#にしたことで、上記例のenum ToioActionTypeのようにどちらも同じ書き方を使い回せます。
コピペで済むし、あっちこっちでこの処理番号はなんだったかな、みたいなことにならなくて良いので便利です。

詳細は省略しますが、toioからの読み取りデータも中継アプリからUnity側にbyte[]をまるっと渡して、Unity側で処理してます。

遊び方を作る

上記のような感じで、中継アプリをはさんでtoioとUnityを連動できるようにしました。

具体的に使った機能は以下のようになります。

ひっぱってパワーを貯める
読み取りセンサーのポジション、回転情報
 
トントンと叩いて走り始める
モーションセンサーのダブルタップ検出
モーター制御の自動走行
 
バーチャルコースの走行コントロール
読み取りセンサーのtoio回転情報
 
バーチャルコースのブレーキ
読み取りセンサーのポジション
 
犬張子と鳩車のキャラクターチェンジ
磁気センサーのSN極判定
 
toioの状態表示ランプ
コース紹介時はランプを赤
操作可能時はランプを青
ゴール時はランプが7色に光る
 
最終結果のtoioダンス
モーター制御の自動走行

Unityのバーチャルコース

Unity側はさほど特殊なことはしてないですが、楽しそうに見えるHDRP・VFX Graphを使ってみる3Dスキャンデータを走らせる、ということを意識して作りました。

HDRPでコースシーンを作成し、PostProcessingで色味等の見た目調整をしています。
この画面の集中線や、ブレーキの火花はVFX Graphで作成してます。
toiq_dash.png

ゴールした時の紙吹雪や、ゴールエリアの光もVFX Graphで作ってます。
toiq_result.png

3Dスキャンキャラクター

キャラクターには、お気に入りの郷土玩具の犬張子と鳩車を用意。
所有している実物を以前に3Dスキャンしたことがあり、バーチャル空間内にも登場してもらうことにしました。

実物の裏側に小さな磁石を貼っており、toioの磁気センサーを使って読み取ることで、リアルとバーチャルの連動感を強めてます。
inuhariko.jpg
hatoguruma.jpg

まとめ

ありがたいことに、コンテストで大賞頂きました。
「ロボットやろうぜ! - toio & Unity 作品動画コンテスト -」結果発表

toioとUnityの得意なことを両方活かすことを意識して作ってみたことが良い結果につながったのだろうかと思ったりします。
コンテスト関係者のみなさまにも厚く御礼申し上げたい所存です。

誰かに遊んでもらえる機会があれば良いなと思っているので、もしどこかでみかけることがあればよろしくお願いします。

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

MonoBehaviourの初期化に Awake() Start()は使用しない

PONOS Advent Calendar 2020の20日目の記事です。
前日記事:@MilayYadokari [記事名]
翌日記事:@FW14B [【Unity】32bitアーキテクチャを切り捨てれば開発効率が上がる]

便利なイベント関数

UnityのMonoBehaviourに用意されているイベント関数。
記述しておけば特定のタイミングに自動で呼び出され、非常に便利です。
ところが、何も考えずに利用を続けると大きな落とし穴に成長してしまいます。
そこで今回は初期化に関する関数 Awake()と Start()について、一記事書いていきます。

記事投稿時点での使用 Unity 2019.4.8f1

Unityの気分次第

全てのオブジェクトをAwake()やStart()で初期化していると、
ディレクターやマネージャー等の管理者的な上位クラスよりも、大量に配置されている敵キャラ等の末端クラスの方が先に初期化される場合があります。結構順番がランダムです。
もし末端クラスで上位のクラスを参照していれば、ゲームを再生する度に nullが出たり出なかったりといった事態に陥ります。

それぞれのイベント順序は明確に決まっていますが、同じイベント関数はどのオブジェクトから呼び出されるのか保証されていません。

イベント順序は公式マニュアルで↓
https://docs.unity3d.com/ja/2019.4/Manual/ExecutionOrder.html

また、自動呼び出しなので任意のタイミングで初期化ができません。

実行順の指定はできるが…

スクリーンショット 2020-12-16 15.57.57.png
Edit → ProjectSettings → Script Execution Order からスクリプト毎に実行順を
指定できます。
ですがこの方法では新規スクリプトを作成する度に設定する必要があり、数が増えてくるとこのウィンドウでは扱いきれなくなるのでオススメできません。

プロジェクト全体での順序より、とあるシーンのとあるオブジェクト群での順序を明確にできればそれで良いといった場合の方が多いと思います。

手動で行えばいい

対応策はごく単純です。
Unityに自動で呼び出させていたところを手動で呼び出すようにすれば良いだけです。

public class SceneInitializer : MonoBehaviour
{
    [SerializeField] PlayerChara playerChara = default;

    void Awake() // Start()でも良い
    {
        // オブジェクトを順に初期化(変数は一例)
        masterData.Initialize();
        playerChara.Initialize();
        enemyManager.Initialize();
        // 完了後は不要なので自壊
        Destory(this);
    }
}

public class PlayerChara : MonoBehaviour
{
    // Awake()は書かずに自作関数を
    public void Initialize() {}
}

具体的にはシーン初期化用のスクリプトを用意し、Awake()はそこだけに記述する。
そのAwake()内で各オブジェクト群の初期化処理を意図通りの順番で記述すればOK。
シーンの初期化が完了すれば初期化用スクリプトをDestroyすればスッキリ済みます。

ヒエラルキーの各所にバラバラと配置してあるオブジェクトも、ここで一元化することができます。

また動的にオブジェクトを生成する場合も、生成されたオブジェクトのAwake()は使わず、生成元で初期化処理を呼ぶようにします。

非同期処理に向けてのアレンジ

public class SceneInitializer : MonoBehaviour
{
    [SerializeField] PlayerChara playerChara = default;

    IEnumerator Start()
    {
        yield masterData.Initialize();
        yield playerChara.Initialize();
        yield enemyManager.Initialize();
        Destory(this);
    }
}

public class PlayerChara : MonoBehaviour
{
    // 戻り値をIEnumerator型にしてコルーチンに対応
    public IEnumerator Initialize()
    {
        yield break;
    }
}

Start() は戻り値の型をIEnumeratorにすることでコルーチン化させることもできます。
各初期化処理の方も IEnumerator にすることで、アセットの非同期読み込みなどの完了を待たせられます。
AddressableAssetsは読み込みが非同期なので、待機させる処理がどこかしら必要になってくると思います。

後の禍根にならないよう

新機能の実装などでは都度テストしながらになるため、勝手に呼び出される Awake()で済ませがち。
意図した機能を作ることに注力するので、くっつけるだけで動作するAwake()はよく使います。
ですがそのまま忘れて見返すことなく開発が進み、同じ構造がどんどん増えてくると…

早めの見直し&対応を心がけたいところです。

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

【Unity】32bitアーキテクチャを切り捨てれば開発効率が上がる

PONOS Advent Calendar 2020の21日目の記事です。
昨日は@UdonLoveさんの「Monobehaviourのイベント関数をなるべく使わない」でした。

はじめに

時間のない方のために先に結論を言っておきます。

ビルド対象から32bitアーキテクチャを排除すれば、サイズが小さくなりビルド時間も短縮できる!

以下、それについて紹介している記事になります。

Architecture設定の警告

Uity2019.4で開発中のある日、
PlayerSetingsのiOSビルド設定にて警告が出ていることに気付きました。
スクリーンショット 2020-12-16 14.28.36.png
日本語にすると「ARMv7(32bits)は非推奨かつUnity2020.1から項目削除されますよ」という内容です。

このArchitectureという項目についてはデフォルトのまま「Universal」にしていたのですが、それだとARM64(64ビット)とARMv7(32ビット)のどちらも有効になっていたようです。

32ビット切り捨て

数年前なら兎も角、流石にもう32ビットの端末は切り捨てて良いだろうと考えます。
Apple A6を使ったiPhone5/iPhone5c/iPad4以前の機種が該当しますが...もういいですよね?

スクリーンショット 2020-12-16 14.29.00.png

Architectureという項目を「Universal」から「ARM64」に変更しました。

これで警告も消えたし、スッキリいい気分!
その時はそれだけで満足していました。

アプリサイズ減

そして翌日、いつものように端末ビルドを手動で行っていると、あれ?もう終わってる...
アプリサイズもいつもより小さい気がする...

ということで、調査してみた結果が以下の表になります。

Architecture設定 Universal ARM64 比較
Unity iOS端末ビルド時間 7分 7分 変わらず
Xcode Archive時間 11分 6分 45%減
ipaファイル作成時間 17分 9分 47%減
Archiveサイズ 1.32GB 0.74GB 44%減
ipaファイルサイズ 78.4MB 61.1MB 22%減
アプリサイズ 74.8MB 58.3MB 22%減

※MacPro(Late2013)での計測です。

それよりもビルド時間減

アプリサイズが小さくなったことも嬉しいですが、ビルド時間がほぼ半減!というのがとても嬉しいです。
無駄な待ち時間が減り、作業が捗ります。

もちろん、本当に32ビットアーキテクチャの端末を切り捨てちゃっていいか否かは方針にもよるので、事前にチーム内でご相談ください。

以上

明日は@caramel_cafeさんです。

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

Unityでアニメーション表現を行うにあたってエディタ拡張した話

経緯

 先日まで『カラクリショウジョの涙と終』というゲームを制作していました。
 このゲームはアニメーションRPGというジャンルをとっており、全てのグラフィックを手書きアニメーションの要領で構成していています。
30.png
 制作の過程で、いくつかの作業をエディタ拡張によって効率化したため、そちらについてまとめました。
 (アニメーション表現自体はこちらでまとめております。興味がございましたら。)
 使用したUnityのバージョンは2019.4.4f1です。

発光処理の工程削減

 今回のグラフィックは『発光』を強調する方向性で効果を加えているのですが、絵における発光部位の指定には白黒のマップ画像を用いています。

 マップ画像作成の工程は『発光させたいを白に、それ以外の色は黒に塗りつぶす』と非常に単純です。
 またアニメーションの絵は、制作の都合上色の境目がくっきりとしておりアンチエイリアスもかかっていません。(ジャギーが発生している状態です)
BattleStart_01のコピー.png

 そのため特定色の抽出を非常に容易かつ機械的に行うことができます。この性質を活かして自動生成処理をエディタ拡張で実装し作業コストの軽減を図りました。

マップ生成

 まず発光部分の色を指定する処理からです。同じキャラクターの絵を扱う際には指定する色も同じになるため、色の情報はSciptableObject化し再利用可能にしています。

MappingTargetColorData.cs
using UnityEngine;

[CreateAssetMenu(fileName = "MappingTargetColorData",menuName ="EmissionMapGenerate/MappingTargetColorData",order =0)]
public class MappingTargetColorData :ScriptableObject
{

    [SerializeField] Color[] targetColors;
    public Color[] TargetColors { get { return targetColors; } }

}

(目のハイライトと白目の部分は同じ白に見えますが、RGBの値を調整しハイライトのみ指定するよう工夫しています)


マップ生成処理自体は以下のようになっています。

   private void DrawMap(Texture2D origin) {

        //同じサイズの空のテクスチャを生成
        Texture2D generatedMap = new Texture2D(origin.width, origin.height, TextureFormat.RGB565, false);

        //元の絵のピクセルを一つずつ確認し、発光させたい色だったら白、そうでなれば黒を塗る。
        for (int y = 0; y < generatedMap.height; y++) {

            for (int x = 0; x < generatedMap.width; x++) {

                if (mappingTargetColorData.TargetColors.Contains(origin.GetPixel(x, y))) {

                    generatedMap.SetPixel(x, y, Color.white);

                }
                else {

                    generatedMap.SetPixel(x, y, Color.black);

                }

            }

        }

        //画像の変更を保存
        generatedMap.Apply();

        //画像形式にして書き込み。
        string mapPath = /* パスを入れてください。".jpeg" など拡張子を忘れずに。 */;

        var bytes = generatedMap.EncodeToJPG();
        File.WriteAllBytes(mapPath, bytes);

        EditorUtility.SetDirty(generatedMap);


    }

 この処理を扱うウィンドウをEditorWindowで合わせて実装しました。
 コードは以下の通りです。

EmissionMapGenerator.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using System.IO;
using System.Linq;
using UniRx;


public class EmissionMapGenerator : EditorWindow {

    List<Texture2D> originTextures = new List<Texture2D>();

    string saveDirectoryPath;

    [SerializeField] MappingTargetColorData mappingTargetColorData;

    [MenuItem("Window/EmissionMapGenerator")]
    static void Init() {
        var window = (EmissionMapGenerator)GetWindow(typeof(EmissionMapGenerator));
        window.Show();
    }

    private const string emissionHeader = "Emission_";

    private const string AssetPathHeader = "Assets/";
    private string SaveDirectoryPathResult { get { return AssetPathHeader + saveDirectoryPath; } }


    public void OnGUI() {

        var own = new SerializedObject(this);

        own.Update();

        using (new GUILayout.HorizontalScope()) {
            saveDirectoryPath = EditorGUILayout.TextField("Save Directory Path", saveDirectoryPath);
        }

        using (new GUILayout.HorizontalScope()) {
            EditorGUILayout.PropertyField(own.FindProperty("mappingTargetColorData"), false);
        }

        own.ApplyModifiedProperties();

        using (new GUILayout.HorizontalScope()) {
            if (saveDirectoryPath == "" || saveDirectoryPath == null) {
                EditorGUILayout.HelpBox("保存先パスが未入力です。", MessageType.Warning);
            }

        }

        using (new GUILayout.HorizontalScope()) {
            if (mappingTargetColorData == null) {
                EditorGUILayout.HelpBox("カラーデータが選択されていません。", MessageType.Error);
            }
        }

        if (GUILayout.Button("Genarate")) {

            if (mappingTargetColorData == null) return;

            originTextures.Clear();

            List<string> originTexturePaths = Directory.GetFiles("Assets/MapGenerateTarget")//Assets直下に専用のファイルを作成し、その中の画像からマップを生成します。
                .Where((path) => IsTexturePath(path))
                .Select((path) => {
                    ChangeTextureToDrawableMap(path);//元の画像から情報を取得できるようにしています。
                    return path;
                })
                .ToList();

            originTextures = originTexturePaths
                .Select((path) => AssetDatabase.LoadAssetAtPath<Texture2D>(path))
                .ToList();

            GenerateMaps();

            for (int i = 0; i < originTexturePaths.Count; i++) {
                string path = originTexturePaths[i];
                ChangeTextureNotToReadable(path);

                //元の画像とマップ画像を同じファイルにまとめています。
                AssetDatabase.MoveAsset(path, SaveDirectoryPathResult + "/" + originTextures[i].name + Path.GetExtension(path));

            }

            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

        }

    }


    private bool IsTexturePath(string path) {

        if (path.Contains(".meta")) return false;
        if (path.Contains(".DS_Store")) return false;
        return true;

    }


    private void ChangeTextureToDrawableMap(string path) {

        var textureImporter = AssetImporter.GetAtPath(path) as TextureImporter;
        textureImporter.isReadable = true;
        textureImporter.SaveAndReimport();

    }


    private void ChangeTextureNotToReadable(string path) {

        var textureImporter = AssetImporter.GetAtPath(path) as TextureImporter;
        textureImporter.isReadable = false;
        textureImporter.SaveAndReimport();

    }


    private void GenerateMaps() {

        Directory.CreateDirectory(SaveDirectoryPathResult);

        foreach (var origin in originTextures) {
            DrawMap(origin);
        }

        AssetDatabase.SaveAssets();
        AssetDatabase.Refresh();

    }


    private void DrawMap(Texture2D origin) {

        //同じサイズの空のテクスチャを生成
        Texture2D generatedMap = new Texture2D(origin.width, origin.height, TextureFormat.RGB565, false);

        //元の絵のピクセルを一つずつ確認し、発光させたい色だったら白、そうでなれば黒を塗る。
        for (int y = 0; y < generatedMap.height; y++) {

            for (int x = 0; x < generatedMap.width; x++) {

                if (mappingTargetColorData.TargetColors.Contains(origin.GetPixel(x, y))) {

                    generatedMap.SetPixel(x, y, Color.white);

                }
                else {

                    generatedMap.SetPixel(x, y, Color.black);

                }

            }

        }

        //画像の変更を保存
        generatedMap.Apply();

        string mapPath = SaveDirectoryPathResult + "/" + emissionHeader + origin.name + ".jpeg";


        //画像形式にして書き込み。
        var bytes = generatedMap.EncodeToJPG();
        File.WriteAllBytes(mapPath, bytes);

        EditorUtility.SetDirty(generatedMap);

    }


}

スクリーンショット 2020-12-16 10.28.31.png
※ウィンドウ名が異なっていますがプログラムの方を参考にしていただければ大丈夫です。

アニメーションさせるにあたって

 今回のアニメーションはパラパラ漫画のように画像を切り替えるだけですので、Unity標準のキーフレーム処理のみで構成されておりシンプルです。ですが元のアニメーションに合わせてマップ画像も変更する必要があるため、同じタイミングに二つもキーフレームを打たなければなりませんでした。
 この二度手間をなくすために、マップ切り替えのキーフレームをエディタ拡張で自動入力するようにしています。
 また今回はAnimationClipだけでなく、Timelineから直で入力したキーフレームにも対応させています。

PlayableAssetの取り扱い

 Timelineで直に入力したアニメーションは、情報を保持するPlayableAssetの下に子オブジェクトのように存在します。
スクリーンショット 2020-12-16 13.59.44.png
 このようなアセットは『SubAsset』という方法で管理されており、アクセスするにはLoadAllAssetsAtPath()を使います。
 取得したAsset郡には親にあたるPlayableAssetの情報も含まれているため、AnimationClipのみ抜き出したい場合は注意が必要です。
(SubAssetについてはこちらを参考にしました。)

マップ画像の抽出

 次に元の絵に対応したマップ画像をAssetの中から検索する必要があります。
 前述した通り今回はマップ画像を自動生成していたため、命名にも規則ができていました。(元画像名の冒頭に"Emission_"が加わるだけ)
 そのため今回はその規則に基づき、名前からマップ画像を検索、抽出しています。

キーフレーム入力

 続いて実装内容についてです。
 まず元のアニメーションの情報を取得する必要があります。

        EditorCurveBinding[] curveBindings = AnimationUtility.GetObjectReferenceCurveBindings(clip);

        //対応するEditorCurveBindingが無い=>絵の切り替えを取り扱っていないAnimationClipのためreturn
        if (!curveBindings.Any(binding => binding.propertyName == "元の絵を扱う変数名")) return;

        //絵の切り替えを行なっているEditorCurveBindingを取得
        var targetCurveBinding = curveBindings.First(binding => binding.propertyName == "元の絵を扱う変数名");

 情報はEditorCurveBindingというクラスで管理されており、このクラスはアニメーションで扱う値ごとにAnimationClip内に存在します。
 その情報をAnimationUtility.GetObjectReferenceCurveBindings()でまとめて取得し、そこから絵の切り替えを取り扱っているものを抽出します。
 判定にはEditorCurveBindingに用意されているpropertyName(アニメーションさせている変数名)を利用しました。


 次に元のアニメーション情報を利用して新たにキーフレームを定義します。

        var keyframes = new List<ObjectReferenceKeyframe>();

        foreach (var reference in AnimationUtility.GetObjectReferenceCurve(clip, targetCurveBinding)) {

            //マップ画像の名前
            string emissionMapName = "Emission_" + reference.value.name;

            //マップ画像のパスを取得
            var targetMapPath = texturePaths.FirstOrDefault(target => target.Contains(emissionMapName));

            //パスからマップ画像を取得
            var emissionMap = AssetDatabase.LoadAssetAtPath<Texture2D>(targetMapPath);

            //時間に対応させてキーフレームを定義
            keyframes.Add(new ObjectReferenceKeyframe {
                time = reference.time, //時間
                value = emissionMap //入力内容
            });

        }

 キーフレームの情報はObjectReferenceKeyframeクラスで扱われており、AnimationUtility.GetObjectReferenceCurve()でAnimationClipから配列で取得できます。
 そして取得したキーフレームそれぞれの時間を参照し、同じタイミングでマップ画像を切り替えるようキーフレームを定義する、といった流れです。
 ObjectReferenceKeyframeに関してはこちらを参考にしました。


 最後に、新たなEditorCurveBindingを生成し、AnimationUtility.SetObjectReferenceCurve()でAnimationClipに適用します

        EditorCurveBinding emissionMapCurve = new EditorCurveBinding() {
            path = string.Empty,
            type = typeof(/*マップ画像の変更を取り扱うクラス*/),
            propertyName = /*マップ画像を扱う変数名*/
        };

        //適用
        AnimationUtility.SetObjectReferenceCurve(clip, emissionMapCurve, keyframes.ToArray());

コード全文

 以上の処理を利用するにあたって、前述したマップ画像生成と同じくEditorWindowを用いています。

EmissionKeyFrameSetter.cs
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;
using UnityEngine.Playables;
using System.IO;
using System.Linq;

public class EmissionKeyFrameSetter : EditorWindow {

    [MenuItem("Window/EmissionKeyFrameSetter")]
    static void Init() {
        var window = (EmissionKeyFrameSetter)GetWindow(typeof(EmissionKeyFrameSetter));
        window.Show();
    }

    string saveDirectoryPath;
    private const string AssetPathHeader = "Assets/";

    string[] texturePaths;

    public void OnGUI() {

        using (new GUILayout.HorizontalScope()) {
            saveDirectoryPath = EditorGUILayout.TextField("Save Directory Path", saveDirectoryPath);
        }

        using (new GUILayout.HorizontalScope()) {

            if (IsDirectoryEmpty) {
                EditorGUILayout.HelpBox("保存先パスが入力されていません", MessageType.Warning);
            }

        }

        if (GUILayout.Button("Set")) {

            string saveDirectoryPathResult = AssetPathHeader + saveDirectoryPath;

            if (!Directory.Exists(saveDirectoryPathResult)) {
                Directory.CreateDirectory(saveDirectoryPathResult);
                AssetDatabase.SaveAssets();
                AssetDatabase.Refresh();
            }

            //専用のファイルをAsset直下に作り、そこにあるAnimationClipやPlayableAssetに対して処理する
            var clipPaths = Directory.GetFiles("Assets/EmissionSetter").Where(path => !path.Contains("meta"));

            //SubAsset取得
            var subClips = new List<AnimationClip>();

            foreach (string path in clipPaths.Where(path => path.Contains("playable"))) {

                subClips.AddRange(AssetDatabase.LoadAllAssetsAtPath(path)
                    .Where(asset => IsRecordedAnimAsset(asset))
                   .Select(asset => (AnimationClip)asset)
                   .ToList());

                //PlayableAssetを保存先に移動
                string newPath = saveDirectoryPathResult + "/" + Path.GetFileName(path);
                AssetDatabase.MoveAsset(path, newPath);
                EditorUtility.SetDirty(AssetDatabase.LoadAssetAtPath<PlayableAsset>(newPath));
            }

            //全テクスチャ情報を取得
            texturePaths = Directory.GetFiles("Assets/Textures", "*", SearchOption.AllDirectories)
                .Where(texturePath => !texturePath.Contains("meta"))
                .ToArray();

            //AnimationClipに対して処理
            foreach (var path in clipPathes) {

                var clip = AssetDatabase.LoadAssetAtPath<AnimationClip>(path);

                if (clip == null) break;

                SetCurve(clip);

                AssetDatabase.MoveAsset(path, saveDirectoryPathResult + "/" + Path.GetFileName(path));
                EditorUtility.SetDirty(clip);
            }

            //SubAssetに対して処理
            foreach (var subClip in subClips) {
                if (subClip == null) break;
                SetCurve(subClip);
            }

            AssetDatabase.SaveAssets();
            AssetDatabase.Refresh();

        }

    }


    private bool IsDirectoryEmpty {

        get {
            if (saveDirectoryPath == null) return true;
            if (saveDirectoryPath == "") return true;
            return false;
        }

    }


    private bool IsRecordedAnimAsset(Object asset) {
        if (!asset.name.Contains("Recorded")) return false;
        if (asset.GetType() != typeof(AnimationClip)) return false;
        return true;
    }


    void SetCurve(AnimationClip clip) {

        EditorCurveBinding[] curveBindings = AnimationUtility.GetObjectReferenceCurveBindings(clip);

        //対応するEditorCurveBindingが無い=>絵の切り替えを取り扱っていないAnimationClipのためreturn
        if (!curveBindings.Any(binding => binding.propertyName == "元の絵を扱う変数名")) return;

        //絵の切り替えを行なっているEditorCurveBindingを取得
        var targetCurveBinding = curveBindings.First(binding => binding.propertyName == "元の絵を扱う変数名");

        var keyframes = new List<ObjectReferenceKeyframe>();

        foreach (var reference in AnimationUtility.GetObjectReferenceCurve(clip, targetCurveBinding)) {

            //マップ画像の名前
            string emissionMapName = "Emission_" + reference.value.name;

            //マップ画像のパスを取得
            var targetMapPath = texturePaths.FirstOrDefault(target => target.Contains(emissionMapName));

            //パスからマップ画像を取得
            var emissionMap = AssetDatabase.LoadAssetAtPath<Texture2D>(targetMapPath);

            //時間に対応させてキーフレームを定義
            keyframes.Add(new ObjectReferenceKeyframe {
                time = reference.time, //時間
                value = emissionMap //入力内容
            });

        }

        EditorCurveBinding emissionMapCurve = new EditorCurveBinding() {
            path = string.Empty,
            type = typeof(/*マップ画像の変更を取り扱うクラス*/),
            propertyName = /*マップ画像を扱う変数名*/
        };

        //適用
        AnimationUtility.SetObjectReferenceCurve(clip, emissionMapCurve, keyframes.ToArray());

    }


}

スクリーンショット 2020-12-16 17.43.08.png

まとめ

 以上のような自動化により単純な作業を減らすことで、作業効率は大きく改善されました。
 ここまで深くエディタ拡張に触れたのは初めてでしたが、どなたかの参考になりましたら幸いです。

参考にさせていただいた記事

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

【エンジニア初心者用】読みやすいソースコードの作り方

PONOS Advent Calendar 2020の19日目の記事です。
昨日は@sunagimo_1111さんのiCloud・iTunesのバックアップを考慮したUnityのデータ保存における注意点でした。

この記事について

この記事ではプログラミングを始めたばかりの方、エンジニア初心者向けの読みやすいソースコードの作り方について
簡単に説明させていただきます。

読みにくいソースコードだと・・・

    [SerializeField] AudioClip se1;
    [SerializeField] AudioClip se2;
    [SerializeField] AudioClip se3;
    [SerializeField] AudioClip se4;
    [SerializeField] AudioClip se5;
    [SerializeField] Text score;
    [SerializeField] GameObject Barrier;
    [SerializeField] GameObject Wing;
    SpriteRenderer sprite;
    Rigidbody2D RigidBody;
    [SerializeField] AudioSource audioSource;
    [SerializeField] float JumpPower;
    [SerializeField] float SecondJumpPower;
    float BarrierTime = 0.0f;
    float WingTime = 0.0f;
    float MoveScorePoint;
    const float SeVolumeNum = 0.3f;
    [SerializeField] int MoveSpeed = default;
    int JumpNum = 0;
    int MoveScore;
    int NowMoveSpeed = 0;
    bool isDead;
    bool isDamege;
    bool isPause = false;
    bool isWing = false;
    Color BarrierNowColor = new Color(1.0f, 1.0f, 1.0f, 0.25f);
    Color BarrierNoneColor = new Color(1.0f, 1.0f, 1.0f, 1.0f);
    void Start()
    {
        NowMoveSpeed = MoveSpeed;
        isDamege = false;
        isDead = false;
        RigidBody = GetComponent<Rigidbody2D>();
        sprite = GetComponent<SpriteRenderer>();
        score.text = MoveScore + "m";
    }
    void Update()
    {
        if (GameDataNum.UserBarrierOn)
        {
            Barrier.SetActive(true);
        }
        else
        {
            Barrier.SetActive(false);
        }
        if (BarrierTime > 0)
        {
            sprite.color = BarrierNowColor;
            BarrierTime -= 1 * Time.deltaTime;
        }
        else
        {
            BarrierTime = 0;
            sprite.color = BarrierNoneColor;
        }
        if (isDead)
        {
            MoveSpeed = -1;
            transform.Rotate(new Vector3(0, 0, -2.5f));
            score.text = " ";
            GameDataNum.UserBarrierOn = false;
            transform.position = new Vector3(transform.position.x + MoveSpeed * Time.deltaTime, transform.position.y, transform.position.z);
            return;
        }
        if (isWing)
        {
            transform.position = new Vector3(transform.position.x + MoveSpeed * Time.deltaTime, 5.50f, transform.position.z);
            if (WingTime > 0)
            {
                WingTime -= 1 * Time.deltaTime;
                Wing.SetActive(true);
            }
            else
            {
                Wing.SetActive(false);
                JumpNum = 1;
                RigidBody.velocity = new Vector3(0.0f, 0.1f, 0.0f);
                WingTime = 0;
                isWing = false;
            }
        }
        else
        {
            if (isDamege)
            {
                if (GameDataNum.UserUserRevivalOn)
                {
                    audioSource.PlayOneShot(se3);
                    transform.position = new Vector3(-19.4f, 10.0f, transform.position.z);
                    RigidBody.velocity = new Vector3(0.0f, 1.0f, 0.0f);
                    isDamege = false;
                    transform.rotation = new Quaternion(0.0f, 0.0f, 0.0f, 0.0f);
                    BarrierTime = 3.0f;
                    MoveSpeed = NowMoveSpeed;
                    GameDataNum.UserUserRevivalOn = false;
                }
                else
                {
                    audioSource.PlayOneShot(se3);
                    isDead = true;
                }
            }
            if (transform.position.y < -5.0f)
            {
                audioSource.volume = 1.0f;
                isDamege = true;
            }
            transform.position = new Vector3(transform.position.x + MoveSpeed * Time.deltaTime, transform.position.y, transform.position.z);
        }
        if (MoveSpeed > 0)
        {
            MoveScorePoint += GameDataNum.StageMoveSpeed * Time.deltaTime;
            if (MoveScorePoint > 2.0f)
            {
                MoveScorePoint = 0.0f;
                MoveScore++;
            }
        }
        score.text = MoveScore + "m";
    }
    public void SetIsPause(bool flag)
    {
        isPause = flag;
    }

これは私がエンジニアになって日が浅いときに書いたソースコードです。
このソースコード、とても読みにくいですね。

読みやすいソースコードの利点、それは・・・

どんな処理をしているかがすぐに理解できる。

1週間、1ヶ月ぶりにソースコードを開いたときどんな処理をしていたか覚えていますか?

もし忘れていた場合、自分のコードの理解するのに時間を使ってしまいます。
個人での開発ならまだしも、チームでの開発ならばチームの誰かがの自分がコードを見ることがあります。
読みにくいソースコードだと理解するのに時間がかかってしまい、その時間が非常にもったいないです。

そうらないためにも読みやすいソースコードはとても重要です。
今回はこのコードを綺麗にしたいと思います。

1: コメント文を書く

            //もし、ダメージを受けたら
            if (isDamege)
            {
                //もし、復活アイテムを所持していたなら
                if (GameDataNum.UserUserRevivalOn)
                {
                    audioSource.PlayOneShot(se3);
                    //プレイヤーの位置を真上に変更し、velocityをリセット
                    transform.position = new Vector3(-19.4f, 10.0f, transform.position.z);
                    RigidBody.velocity = new Vector3(0.0f, 1.0f, 0.0f);
                    //ダメージフラグをFalse
                    isDamege = false;
                    transform.rotation = new Quaternion(0.0f, 0.0f, 0.0f, 0.0f);
                    BarrierTime = 3.0f;
                    MoveSpeed = NowMoveSpeed;
                    //復活アイテムの消費
                    GameDataNum.UserUserRevivalOn = false;
                }
                else
                {
                    audioSource.PlayOneShot(se3);
                    isDead = true;
                }
            }
            //一定の高さまでプレイヤーが落下したら
            if (transform.position.y < -5.0f)
            {
                audioSource.volume = 1.0f;
                isDamege = true;
            }

処理の横に軽く添えるだけで理解しやすさは大きく上昇します。

やりすぎはよくない...

            //もし、ダメージを受けたら
            //プレイヤーが無敵時間じゃない場合、トラップに当たるとダメージを受けてしまいます。
            if (isDamege)
            {
                //もし、復活アイテムを所持していたなら
                //アイテムを消費させプレイヤーの位置はステージの真上から
                //無敵時間を用意したあげています
                //角度も0にしています
                if (GameDataNum.UserUserRevivalOn)
                {
                    //音を鳴らしてます。なる音については「キュイン」です
                    audioSource.PlayOneShot(se3);
                    //プレイヤーの位置を真上に変更し、velocityをリセット
                    transform.position = new Vector3(-19.4f, 10.0f, transform.position.z);
                    //なぜここで1.0fなのかというと落下速度が大変なことになってしまいますので
                    //落下速度をリセットするために
                    RigidBody.velocity = new Vector3(0.0f, 1.0f, 0.0f);
                    //ダメージフラグをFalse
                    isDamege = false;
                    //↑ これでダメージ判定がまた取れます
                    //角度をリセットしてます。
                    transform.rotation = new Quaternion(0.0f, 0.0f, 0.0f, 0.0f);
                    //バリア有効時間は3秒です
                    //Update内で減らして、0になるとバリアが消えます
                    BarrierTime = 3.0f;
                    MoveSpeed = NowMoveSpeed;
                    //復活アイテムの消費
                    //この復活アイテムはショップで購入できます。
                    GameDataNum.UserUserRevivalOn = false;
                }
                else
                {
                    audioSource.PlayOneShot(se3);
                    isDead = true;
                }
            }
            //一定の高さまでプレイヤーが落下したら
            //画面下の一定の高さより下に行くとプレイヤーは死にます。
            if (transform.position.y < -5.0f)
            {
                //音量を1にしています。
                audioSource.volume = 1.0f;
                isDamege = true;
            }

2: 関数化を使う

void Update()にたくさん書いてしまうと読みにくいです。
処理に部類わけし、関数を作っておくと見たい処理がわかりやすくなります。

void Update()
{
        MoveSpeed = -1;
        transform.Rotate(new Vector3(0, 0, -2.5f));
        score.text = " ";
        GameDataNum.UserBarrierOn = false;
        transform.position = new Vector3(transform.position.x + MoveSpeed * Time.deltaTime, transform.position.y, transform.position.z);
        return;

etc...
}
//これをUpdateにDead()こう書くだけ
    void Dead()
    {
        MoveSpeed = -1;
        transform.Rotate(new Vector3(0, 0, -2.5f));
        score.text = " ";
        GameDataNum.UserBarrierOn = false;
        transform.position = new Vector3(transform.position.x + MoveSpeed * Time.deltaTime, transform.position.y, transform.position.z);
        return;
    }

3: 変数の名前もわかりやすく

    [SerializeField] AudioClip se1;
    [SerializeField] AudioClip se2;
    [SerializeField] AudioClip se3;
    [SerializeField] AudioClip se4;
    [SerializeField] AudioClip se5;

//修正
    [SerializeField] AudioClip seJump;
    [SerializeField] AudioClip seCoin;
    [SerializeField] AudioClip seDead;
    [SerializeField] AudioClip seBarrier;
    [SerializeField] AudioClip seWing;

変数は名前を見ただけでどんな役割の変数なのかもわかるように命名しておきましょう。

4: マジックナンバーを減らす

transform.position = new Vector3(-19.4f, 10.0f, transform.position.z);
「-19.4fってなんですか?」
if (transform.position.y < -5.0f)
「これってなんの数字なの?」

こういったマジックナンバーは一目見ただけでは意味が理解しにくく、値を変えたいときも不便です。

Vector3 restartPosition =new Vector3(-19.4f, 10.0f, transform.position.z);

playerDeadPositionYだったり stageHeight
など後から編集しやすい書き方がいいでしょう。
また、変数にすることによって同じ数字を使いたいときに同じ変数を書くだけで済むので、構築も楽です。

5: 改めて見返す

「あの時は勢いで書いたけど・・・」
「この処理短くできないかな?」
落ち着いて考えて見返すことで、無駄な処理を少しでも減らし見やすくなります。

まとめ

ソースコードを綺麗にすることは自分自身の成長に大きく繋がります。
特にチーム開発ではいろんな人が自分のコードを見ることになると思いますので
見やすくするのは必須と言ってもいいでしょう。
就職活動中の学生ならばソースコードを綺麗にするだけで評価は大きく上昇すると思います。

最後に

今回は昔、自分が書いたソースコードを見て、記事にしようと思いました。
昔に作ったソースコードなんて見る機会はほとんどなかったため、見たときはあまりの汚さに
思わず笑ってしまいました。

自分はまだまだ未熟ではありますが、エンジニアとして今後もっとより良いソースコードを作っていけたらなと思います。


明日は @udonLovesさんの記事です!

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

非エンジニア向けに簡易的なアニメーションビューア作ってみた

株式会社グレンジでUnityエンジニアをしている @s_ebata と申します。
この記事はグレンジ Advent Calendar 2020の12/17の記事です。

はじめに

タイトルの通りですが非エンジニア向けに超絶簡易的なアニメーションビューア機能を作ってみました。
本来ならUnityのAnimatorウィンドウとAnimationウィンドウを使えば事足りる機能ですが、本機能は以下の点を重視して手早く作ったものになります。ご留意ください。

  • 非エンジニア向け
  • インスペクタで完結する
  • アニメーションの指定と再生・停止ができる

環境

Unity 2018.4.27f1

demo

こんな感じでLayersとStateをプルダウンから選択して再生・一時停止・再開・停止が可能です
画面収録 2020-12-14 10.46.30.gif
Unityちゃんのアニメーションチェックシーン使っているため若干ややこしい見た目になってますが、インスペクタ内からアニメーションの指定と再生・停止・一時停止・再開ができているのが確認出来るかと思います。
解像度が低くて分かりづらいかもですが、ボタンは左からPlay、Pause、Resume、Stopとなっています。

動画には入っていませんが、AnimationEventsの設定有無も確認できます。

実装

難しいことは一切やっていません。ただただAnimator側の情報を取って表示しているだけです。
細かい制御などは端折って記載しています。

// ...省略...

/// <summary>
/// CustomAnimator参照
/// </summary>
private CustomAnimator _customAnimator;

/// <summary>
/// レイヤー名を格納する配列
/// </summary>
private string[] _layerNames = new string[] { };

/// <summary>
/// ステート名を格納するディクショナリ
/// </summary>
private Dictionary<int, string[]> _stateNamesDict = new Dictionary<int, string[]>();

private int _layerIndex;

private CustomAnimator CustomAnimator
{
    get
    {
        if (_customAnimator == null)
            _customAnimator = target as CustomAnimator;
        return _customAnimator;
    }
}

public override void OnInspectorGUI()
{
    // ...省略...

    EditorGUILayout.LabelField("デバッグ再生");
    _layerIndex = EditorGUILayout.Popup("Layers", _layerIndex, _layerNames);

    var stateNames = _stateNamesDict.ContainsKey(_layerIndex) ? _stateNamesDict[_layerIndex] : new string[] { };
    _stateIndex = EditorGUILayout.Popup("States", _stateIndex, stateNames);
    _stateName = stateNames.Length > 0 ? stateNames[_stateIndex] : string.Empty;

    using (new EditorGUILayout.HorizontalScope())
    {
        if (GUILayout.Button("Play"))
        {
            var fullPathHash = Animator.StringToHash(string.Join(".", _animator.GetLayerName(_layerIndex), _stateName));
            CustomAnimator.Play(fullPathHash, _layerIndex, _normalizedTime, () =>
            {
                Debug.Log("Complete");
            });
        }
        if (GUILayout.Button("Pause"))
        {
            CustomAnimator.Pause();
        }
        if (GUILayout.Button("Resume"))
        {
            CustomAnimator.Resume();
        }
        if (GUILayout.Button("Stop"))
        {
            CustomAnimator.Stop();
        }
    }

    // ...省略...
}

// ...省略...

実行時にAnimatorControllerのLayersとStatesの拾い方がわからなくてググりました。
以下方法で拾えることがわかって勉強になりました。

// UnityEditorのAnimatorControllerでキャスト
var controller = _animator.runtimeAnimatorController as UnityEditor.Animations.AnimatorController;
for (var i = 0; i < controller.layers.Length; i++)
{
    var layer = controller.layers[i];
    Debug.Log(layer.name); // レイヤー名

    var states = layer.stateMachine.states;
    foreach (var state in states)
    {
        Debug.Log(state.state.name); // ステート名
    }
}             

以上になります。

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

Blenderで3Dモデルを作ってUnityで表示してみた

はじめに

こんばんは。ごだと申します。
今回はゲームでは欠かせない3Dモデルがどのように作られるのか前より気になっていたので調べてみました。
実際に3Dモデルを作成し、それをUnityでアセットとして利用してみたいと思います。
今回はこのような傘を題材にしていきます。

モデリング

まずは3Dモデルの格子となるメッシュを作成していきます。
今回は記事名の通り、Blenderを使用して以下のようなモデルデータを作成しました。
このデータを作るにあたり、気づいたことを記載します。
2020-12-15_14h17_37.png

モディファイア

モデリングは基本的に立方体などプリミティブな形状に変形や分割といった操作を加えることで形状を作っていくものと思っていましたが、実際にはモディファイアという機械的に形状を作る機能があり、楽することができました。

ミラー

メッシュを左右対称にする機能です。これを使えば実際に作り込むのは1/2で済みます。
2020-12-16_16h44_24.png

配列

メッシュを複製し、等間隔で並べる機能です。
2020-12-16_16h45_56.png

サブディビジョンサーフェス

メッシュを規則的に分割し、複雑な曲面を作れる機能です。
2020-12-16_16h51_31.png

四角形ポリゴン

モデリングは四角形でポリゴンで作ることが良しとされているようです。
五角形や六角形などの多角形ポリゴン、三角形ポリゴンはレンダリングソフトによっては正しく描画されないことがあるようです。
2020-12-15_23h43_06.png

面の向き

ポリゴンには表と裏があり、夢中で作っていると面の向きがごちゃごちゃになっていってしまいます。CGで基本的には裏面は表示されないため、外側にくるところは表面に直す必要があります。

Blenderには表裏を区別して表示する機能があるため、それを使うことで状態を確認することができます。
2020-12-15_20h47_44.png
青色が表面、赤色が裏面となるため、以下の操作ですべて表面に直します。
2020-12-15_20h48_02.png

UV展開

ポリゴンにUVを設定します。
Blenderではシーム(切れ込み)を入れて、3次元のモデルを2次元の画像に展開していきます。
2020-12-16_11h04_34.png

テクスチャ作成

テクスチャの作成にはSubstancePainterを使用します。
SubstancePainterは3Dペイントを行うソフトで、以下のようなレイアウトでモデルとUV展開図を見比べながらテクスチャを作成できます。書き込みは左右どちらのビューでも行うことができます。
2020-12-15_22h34_47.png
マスクで範囲を指定しつつ、ブラシやマテリアルを使って以下のように塗り分けていきます。
マスクはメッシュ単位で作成することができるのが便利です。
2020-12-15_23h33_13.png
Irayで好きなタイミングで見栄えの確認を行うことができるのも嬉しいです。
2020-12-16_11h32_00.png

Unityで表示

これまでで作ったFbxとテクスチャをUnityに持ち込みます。
2020-12-16_12h08_40.png

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

SQLiteUnityKitの読み込み先をResourcesフォルダからにしてみる

この記事は大阪工業大学 Advent Calendar 2020の18日目の記事です。

はじめに

はじめましての方ははじめまして、それ以外の人はこんにちは。
今年度大学に行かなさ過ぎてただのニートと化してるnihuといいます。
軽く自己紹介でもした方がいいかなと思ったんですけど、僕のTwitterのホーム画見たら大体わk
需要がない気がしたので割愛します。
あと今回のはやってること自体は難しくないですがunityちょっと知らないとわからないかもしれない...申し訳ない...

1.目的

UnityでSQLを扱うのに便利なSQLiteUnityKitというものがあります。
詳しくは他にわかりやすい記事かいっぱいあると思うのでここでは詳しく書きませんが、
これを導入すると簡単にデータベースを扱えるようになるアプリです。

で、このアプリ、データベースファイルを読み込む先がstreamingAssetsからに限定されています。
streamingAssets以外に置くとビルド後に場所が変わってしまったりするから当然と言われれば当然なのですが、
streamingAssetsに置くと作成したアプリがデスクトップ向けのものだとフォルダの中にデータベースファイルがそのまま入っていて
少し知っている人なら簡単に中身を見れるという問題があります。(まだ初心者で知らないだけの場合もあるので隠蔽とかする方法あるなら誰か教えてください泣いて喜びます)
前使ったときは見られても問題ないなーぐらいに思ってたんでスルーしてたんですが、
今後作りたいと考えてるものの中で見られると嫌だなというケースが出てきたので、なんか方法ないかなと思って考えたのがResourcesフォルダから読み込む方法です。
(unity詳しい人ならAssetBundleじゃないのって思うと思うんですけどまだ使ったことないのでResourcesからで大目に見てください...)

とりあえず考えたはいいけど実際できるのかと調べた結果できたのでこれからやった方法を纏めていきます。

2.方法の模索

まずSQLiteUnityKitのSqliteDatabaseのコンストラクタを見てデータベースのファイルをどうやって読み込んでいるか見てみました。

SqliteDatabase.cs
public SqliteDatabase (string dbName){

        pathDB = System.IO.Path.Combine (Application.persistentDataPath, dbName);
        //original path
        string sourcePath = System.IO.Path.Combine (Application.streamingAssetsPath, dbName);

        //if DB does not exist in persistent data folder (folder "Documents" on iOS) or source DB is newer then copy it
        if (!System.IO.File.Exists (pathDB) || (System.IO.File.GetLastWriteTimeUtc(sourcePath) > System.IO.File.GetLastWriteTimeUtc(pathDB))) {

            if (sourcePath.Contains ("://")) {
                // Android  
                WWW www = new WWW (sourcePath);
                // Wait for download to complete - not pretty at all but easy hack for now 
                // and it would not take long since the data is on the local device.
                while (!www.isDone) {;}

                if (String.IsNullOrEmpty(www.error)) {                  
                    System.IO.File.WriteAllBytes(pathDB, www.bytes);
                } else {
                    CanExQuery = false;                                     
                }   

            } else {
                // Mac, Windows, Iphone

                //validate the existens of the DB in the original folder (folder "streamingAssets")
                if (System.IO.File.Exists (sourcePath)) {

                    //copy file - alle systems except Android
                    System.IO.File.Copy (sourcePath, pathDB, true);

                } else {
                    CanExQuery = false;
                    Debug.Log ("ERROR: the file DB named " + dbName + " doesn't exist in the StreamingAssets Folder, please copy it there.");
                }   

            }           

        }
    }

色々書かれてますが要約するとandroid以外はデータベースをPersistentDataPathにコピーしてコピーしたものを参照している仕組みだとわかります。
つまりResourcesフォルダからデータを読み込んで同じところにファイルを生成できればあとは全く同じでも問題ないということになります。
ただResourcesフォルダ内のものはよくわからん形式に圧縮されてるので何かいい方法はないかと調べてると以下のサイトをみつけました。

【Unity】ファイルをバイナリとして扱う方法

これを見るとTextAssetというクラスを使うとResourcesそのままのデータを読み込めるらしいのでつまり

ファイルを読み込む→パス作ってSystem.IO.File.WriteAllBytesで読み込んだバイナリデータをファイルに書き込む→ファイル作れた^q^

という流れを汲めば恐らく上手く動作するはずだと考え、次にSqliteDatabaseの書き換えに移りました。

3.ソースコードの書き換え

ほんとは上の方法で正しくコピーできるかとかテストしたんですが、問題なく動いたのと書く時間がないので割愛して書き換え後のSqliteDatabaseのコンストラクタのコードを記述します。

SqliteDatabase.cs
public SqliteDatabase (string dbName){

        pathDB = System.IO.Path.Combine (Application.persistentDataPath, dbName+".db");
        //original path
        //string sourcePath = System.IO.Path.Combine (Application.streamingAssetsPath, dbName);

        //if DB does not exist in persistent data folder (folder "Documents" on iOS) or source DB is newer then copy it
        if (!System.IO.File.Exists (pathDB) /*|| (System.IO.File.GetLastWriteTimeUtc(sourcePath) > System.IO.File.GetLastWriteTimeUtc(pathDB))*/) {

            TextAsset textAsset=(TextAsset)Resources.Load(dbName);
            System.IO.File.WriteAllBytes(pathDB, textAsset.bytes);

            /*if (sourcePath.Contains ("://")) {
                // Android  
                WWW www = new WWW (sourcePath);
                // Wait for download to complete - not pretty at all but easy hack for now 
                // and it would not take long since the data is on the local device.
                while (!www.isDone) {;}

                if (String.IsNullOrEmpty(www.error)) {                  
                    System.IO.File.WriteAllBytes(pathDB, www.bytes);
                } else {
                    CanExQuery = false;                                     
                }   

            } else {
                // Mac, Windows, Iphone

                //validate the existens of the DB in the original folder (folder "streamingAssets")


                if (System.IO.File.Exists (sourcePath)) {

                    //copy file - alle systems except Android
                    System.IO.File.Copy (sourcePath, pathDB, true);

                } else {
                    CanExQuery = false;
                    Debug.Log ("ERROR: the file DB named " + dbName + " doesn't exist in the StreamingAssets Folder, please copy it there.");
                }   

            }*/     

        }
    }

元々のソースコードでいらない部分はコメントアウトして残しています。色々適当だったり殆ど原形なかったりしますがまあこれで行けると思います(適当)。

そしてテスト用に書いたソースコードはこちらです。

test.cs
void Start()
    {
        string test="1";

        SqliteDatabase sqliteDatabase=new SqliteDatabase("testdata");
        string testQuery=string.Format("select * from test where id = '{0}'",test);
        DataTable dataTable=sqliteDatabase.ExecuteQuery(testQuery);

        string name="";

        foreach (DataRow dr in dataTable.Rows){
            name=(string)dr["name"];
            Debug.Log(name);
        }

    }

あとテスト用に作ったデータベースはこんな感じ
スクリーンショット 2020-12-17 225121.jpg

これで必要なものはそろったのでtest.csを適当なオブジェクト作ってアタッチしてみてテストプレイを実行してみます。
スクリーンショット 2020-12-17 225754.jpg

ヤッターーー!!!
というわけでうまく作動できました。

おわりに

絶対他にいい方法ある気しかしない(震え)
初め調べたとき似たようなの出てこなかったので作るかってなったけど後で調べてたらしっかり見てはないけどバインドして隠蔽云々書かれている記事見つけて「これ...いるんか?」ってなったけど見なかったことにしました()
けど今の自分ができるなかでやってみて一応うまくできたので良かったかなと思います(難しいことはしてないけど)
上にも書きましたけどこんなことしなくてもファイル隠せるよなど詳しい人いたら教えてください...

(おわり)

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

【Unity×VSCode】UnityEngine, MonoBehaviour 等、もろもろが認識されてないんだけど?

本記事は、こんなエラーが出た人の役に立つかもしれません:

The type or namespace name 'UnityEngine' could not found.
The type or namespace name 'MonoBehaviour' could not found.

◤ 環境(2020-12-16時点)

  • Windows 10 Home だいたい最新
  • Unity 2019.4系~2020.1系
  • 連携済み VS Code だいたい最新

◤ まえおき

「テキストエディタ VS Code でスクリプトを編集しようと思ったら、エラーが千件単位で出てるんだけど?何なら万単位で出てるんだけど?」となった場合、VS Code で Ctrl + Shift + M を押し、PROBLEMS を確認しましょう。おそらく、冒頭のようなエラーコードが混じっているのではないでしょうか?(違ったらごめんなさい。)

調べると、Unityを再インストールしなくてはならないと言う記事がヒットすることもありますが、忙しいときにはやりたくないものです。今回はそんなときの対処法について書きます。

(根本解決ではないですが、そもそも本事例は特定のバージョンに依存したバグではないので、とりあえず「ふーん、そういう解決方法もあるんだー」程度に読むと良いです。)

◤ 参考リンク

ほぼ、ここに載っているので、まずはこちらを読むと良いです。
本記事で紹介していない対処法についても、まとめてあります。

◤ 対処法

自分が普段行っている手順を示します。
そのときによって原因が違うのですが、自分の環境では次の手順のどこかで解決します。

⫸ Phase 1

  1. まず、プロジェクトフォルダをエクスプローラーで開き、ルートにある .csproj.sln を全て削除します。(cf. 複数選択の記事
  2. VS Codeを終了し、Unityエディタでスクリプトファイルをダブルクリックして、再度 VS Codeを開きます。
  3. (しばらく待ち、正常に稼動すればOKです。エラーが直らない場合は次へ進みます。)

⫸ Phase 2

  1. VS Code の設定を開き( Ctrl + , する)、omnisharp use global mono 等で検索をかけます。
  2. Omnisharp: Use Global Mono の項目で、ドロップダウンリストを操作します。どの項目に設定するかは任意ですが、この操作を行った際に、OmniSharp Server の再起動ボタンが表示されます。
    image.png
    これをクリックして、サーバーを再起動します。
  3. Ctrl + Shift + UOmniSharp Log を選択し、OUTPUTを眺めておきます。しばらく待つと、処理が終了します。
  4. (この時点で VS Code 側において MonoBehaviour 等のエラーが消えていれば問題ありません。解決しない場合は、次へ進みます。)

⫸ Phase 3

  1. プロジェクトフォルダを確認し、.csproj.slnが再生成されていないことを確認します。されている場合は、念のため削除します。
  2. 必要に応じて、Ctrl + S 等したうえで、Unityエディタと VS Code を終了します。その後、再度 Unity Hub からプロジェクトを開きます。
  3. Unityエディタの再起動後、Projectウィンドウから任意のスクリプトを選択し、VS Code を開きます。
  4. VS Code 側が正常に稼動していることを確認します。

◤ さいごに

以上の手順を踏めば、自分の環境ではエディタがきちんと動作するようになります。
ほかのより良い方法や、根本解決/未然防止の方法を知っている方がいれば、ぜひコメント等で共有していただければと思います。

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

【2020年度版】Unityで初学者から中級者までを駆け抜けるためのおすすめ書籍【中学生から大人まで】

はじめに

Unityの書籍紹介は色々あるのですが、初学者へのおすすめ書籍まとめという記事が多い印象です。それ自体はいいのですが、
初学者が、一通り読んで学んだ、その先に何ををすればいいのかというところまで幅広くまとめている記事はあまり見かけないなと思ったので作ります。
Life is Tech ! のCampやSchoolで中高生という初学者と日々コミュニケーションをしているので初学者から中級者がやっぱりメインになりますが、頑張って上級者になるための道のりも引いてみます。

読む上での注意

基本的には、やっと中級にやってきた筆者がこれまで読んできた書籍が中心です。
上級向けとして、ゲームエンジンのアーキテクチャや、ゲームエンジンに頼らない実力が必要と考えている方もいるかもしれませんが、対象から外れていますのであしからず。

また、個人の好みによって同系統の別の方がおすすめ、ということもあるかと思います。
そして著者が読んでいないUnity本も数多あります。
「この場合はこの書籍がおすすめ」ということがあればぜひコメント欄で教えて下さい。
また、書籍画像とリンクはAmazonサイトから主に引用しています。

対象者

  • 初学者:これからUnityを覚えたい!プログラミングを学んでみたい!という人
    • いわゆるプログラミング初心者
  • 中級者:Unityの基本的な動作は覚えられた。ゲームを作ってみた人
    • ゲームは作ってみてからの伸びしろが大きいですよね!
  • 上級者:自分で1つのゲームはすらすら作れるようになって、さらにレベルアップしたい人
    • いわゆるデザイン領域もこのあたりから入ってくるのですが、1人で作ることを今回は想定しています。

初学者から中級者

初学者から中級者までは一直線に駆け抜けていきましょう!

初学者向け(Unityとプログラミングの基本)

いわゆる初心者向けまとめ記事では、この入門教科書を色々紹介してくれていることが多いのですが、
初心者にとって大事なことは、様々な参考書使うより1冊を信じてしっかりやり遂げることだと思っています。
その思いを信じて、今回は思い切って1つのみしか紹介しません!

Unityの教科書 Unity 2020完全対応版

image.png

いわゆる「ねこの本」です。
2Dの内容が多めとなっているが、基本的なUnityの操作からプログラミングまでを覚えることができる。
とくにいいな、この本を紹介したいなと思ったのが、ゲームの作り方を簡潔に説明している点
わかりやすいゲームでのアクションのギミックだけでなく、「監督役、工場」などのわかりやすい単語で、
ゲームを管理する役割までを説明されている点は、自分が人に教えるときに見習いたいと思っています。

中級者向け(1冊を信じてやり遂げた人に贈るもう1冊)

初学者向けよりは詳しく記載されているが、UnityやC#についての基本から一気通貫で網羅している書籍をもう一度やりましょう。
いわゆる「強くてニューゲーム」です。ここでの一冊目は著者がまさにUnityを勉強し直すときに信じてやり遂げた書籍から紹介します。

Unity5 3D/2Dゲーム開発実践入門 Unity2019対応版

image.png

去年辺りまで、Unity5の本だったので紹介しづらかったのですが、2019対応版が発売されたのでこれからは堂々と紹介できます。
ゲームロジックの考え方がわかりやすくて有用だったり、GI(グローバルイルミネーション)など演出として有用な部分も
丁寧に説明されているので、もう一冊基本から初めて、だけど基本だけじゃなく更にゲームを作るための方法を学ぶことができる
素晴らしい書籍だと思っています。

中級者から上級者になるための三種の神器!

さてさて、「それでは上級編!」といきたいところですが、、
Unityは、そしてゲームづくりというものは、とても奥が深いのです。
そのため、もう少しだけ、ゆっくりしっかりとレベルアップをしていきましょう!
それぞれ、下記で紹介する上級向けのジャンルを代表する書籍です。
まずは、この3種類の方向性に進むとといいのかなと思います。

上級者向け

上級編からは、様々なジャンルに分かれて様々な書籍があります。
この記事では、筆者の考えから学びやすいだろうという順番に並べていますが、本質的に順番はないと考えています。
順番を来にせず、興味があるジャンルから学んでいってください。

そして…ここからは遠慮なく物量作戦です。
後半の分量の多さにびっくりさせてしまったかもしれません。
それだけゲームづくりとは複合的で総合的なモノづくりだと思っています。
だからこそ創ることが面白いのです。
それでは上級者への道のりを覗いてみましょう!

ゲームメカニクス

ゲームメカニクスというのは幅広く様々なな意味を持つ単語なのですが、ここでは主にプログラミングやUnityでの操作を多く含む
ゲームの仕組みづくりというものをゲームメカニクスと表しています。

Unityゲーム プログラミング・バイブル

image.png

Unity2018までで、できることを利用した、まさに機能のバイブル。
章ごとに完結しているので、気になった技術から試すことができます。使ったことがない機能を使ってみるという点でもおすすめ。
ただ、Unityは最近の進化が早いので、Unity標準でできることがAssetで紹介されていたりするものもあります(Anima2Dなど)。

Unity デザイナーズバイブル

image.png

プログラミングバイブルが出版されたときの感想は上記の説明に加えて実は、「ProBuilderがあればベストだった。。」と思っていました。
が、まさにそのような声に応えるようなカタチで出版されたプログラミング・バイブルの姉妹編が出版されました!
デザイナーと名前にあるように、プログラマーだけじゃなく幅広い人にUnityの使い方を紹介してくれています。
こちらも素晴らしい書籍なのですが、一点だけ注意点があります。
それは、Amazonnoレビューでもある通り、誤植が多い点です。
ここまでいろんな書籍を通して作品を作っていれば自己解決できるレベルなのですが、再販が行われる際にはブラッシュアップされているとうれしいですね!

ゲームの作り方 改訂版 Unityで覚える遊びのアルゴリズム

image.png

そろそろ、Unityの基本だけではなく、ゲームメカニクスの作り方も気になってきます。
実際ありそうなゲームサンプルにて以下のようなの実現方法を学ぶことができます。

  • タッチ操作
  • 迷路のCSVファイル管理
  • 複数カメラの利用
  • ミスタードリラー的ブロックゲーム
  • シューティングのロックオン管理

サンプル的な意味合いが強いので、考え方を理解して自分で作ってみることをおすすめします!

ゲームデザイン

こちらでは、ゲームメカニクスに比べ、ゲームという仕組みそのものを面白くするための書籍を紹介します。
ゲームデザインという言葉はいろいろな使われ方をしますが、ここではまずはゲームをより面白くするためのゲーム全体の仕組みと考えてみてください。

ゲームデザインバイブル

image.png

これまで、ゲームデザインを学びたいというときに紹介する本は迷っていることが多かったです。
ゼビウスの遠藤さんによるゲームデザイン講義実況中継や、オライリーによるレベルアップのゲームデザインなどを伝えてきましたが、2019年についにこの書籍が紹介されました。
バイブルという名前

ゲームプランとデザインの教科書 ぼくらのゲームの作り方

image.png

この後出てくる、リーダブルコードの部分でも触れているのですが、日本の書籍と海外の書籍は、本というものの作り方が少し違うなぁと感じる時があります。
これは個人的な意見なのですが、日本の書籍の方がよりストーリー的でリスト的で、より具体的な印象を受けます。
ゲームデザインバイブルはシステマチックすぎるなぁと感じた人には、こちらの書籍を読んでみることをおすすめします。
後半にある実際のゲームクリエイターの方々によるインタビューとノウハウ紹介には、刺激を受けると思います。

3Dゲームをおもしろくする技術 実例から解き明かすゲームメカニクス・レベルデザイン・カメラのノウハウ

image.png

こちらの本には、プログラミングは一切出てきません。
ただひたすらにデジタルゲームをゲーム設計としてのメカニクス(格闘ゲームのタイミング設計など)、レベルデザイン、カメラテクニックなどから
分析しています。このような書籍が他にないからこそ、そして実際のゲームが題材だからこそ自分のゲーム体験を省みることにも繋がります。

ゲームメカニクス大全

image.png

ゲームという長い歴史の中で、デジタルゲームが生まれたのはほんの最近に過ぎません。
ゲームというものは、テーブルゲーム、ボードゲームと一緒に発展してきました。
この本では、そのようないわゆるアナログゲームに現れるメカニクスを網羅し分類に挑戦しているという意欲的な一冊です。

おもしろいゲームシナリオの作り方 ―41の人気ゲームに学ぶ企画構成テクニック

image.png

ゲームデザインの中には、もちろんゲームのフィクションというものも含まれます。
この本は、その中でもゲームシナリオだけに注目している本です。
しかし、この中で出てくるヒーローズジャーニーという考え方は確実に学びにつながるのでそこだけでも読む価値があります。
そして、本当に様々な面白いゲームの面白いシナリオを紹介してくれるのであなたのインプットも広げてくれることでしょう。

ゲームデザインについてその他の書籍

まだまだ紹介したい本はたくさんあるぐらい、ゲームデザインとは奥深いのですが一旦ここまで。

  • HALF-REAL
    • ビデオゲームとは何かを考える
  • ゲームの企画書
    • ゲームを作ったクリエイター達は何を考えていたのか
  • 中ヒットに導くゲームデザイン
    • この本の目的は原題にあらわれています。
    • Game Design Workshop: A Playcentric Approach to Creating Innovative Games:革新的ゲームを創るためのプレイ中心アプローチ
      • つまり、ゲーム作りのプロトタイピングについて考えられているものです。

プログラミングとして、C#を学ぶ

プログラミングは、深く知れば知るほど面白い世界です。
一緒にC#という言語と考え方を知っていきましょう。

新・標準プログラマーズライブラリ なるほどなっとく C#入門

image.png

スラスラわかるC#

image.png

前述のなるほどなっとくなどで、クラスやポリモーフィズムなどを理解したら、もう少し深くC#について学んでみましょう。
匿名関数やデリゲートなど、プロパティなど、もう少し深ぼってC#を改めて知ること画できます。

実戦で役立つ C#プログラミングのイディオム/定石&パターン

image.png

上記2冊でC#という言語を学んだら、一歩進んだC#の書き方を身に着けましょう。
ゲーム制作のための書籍ではないのですが、C#らしいコードを学ぶことができます。

UniRx/UniTask完全理解 より高度なUnity C#プログラミング

image.png
上級以上のレベルになってしまうと思われますが、ゲーム制作で非常に便利な考え方はリアクティブプログラミングです。
Unityでは、実質的にUniRXが当てはまります。さらには、C#のTaskをUnity向けに拡張されたUniTaskについても合わせられた、とりすーぷ先生の書籍も紹介されてください。
(筆者もこれから読んで、まずは完全理解したと言えるようにがんばります。)

コーディング一般

ゲームプログラマのためのコーディング技術

image.png

そろそろ、コードが煩雑になってきて管理が難しくなってくる頃。
リーダブルコードももちろんおすすめだけど、こちらのほうが実践的内容から始めるので初学者にはわかりやすいと考えています。
(また、リーダブルコードはTHEアメリカン書籍という体裁なため、普段本を読まない日本系の方だと読みづらいのではないかとも感じています。)

この本では、命名の原則からオブジェクト指向のクラス設計などまで、実践的にプログラミングの設計を学ぶことができます。
特にゲーム制作に役立つ形でのSOLID原則のわかりやすい説明は多くの方に紹介したいです。

プリンシプル オブ プログラミング3年目までに身につけたい一生役立つ101の原理原則

image.png

KISS?DRY?SOLID?YAGNI?疎結合??
などなどプログラミングを調べていると出てくる様々な先人たちの研鑽の結果を手早く知ることができます。
この本にはコードは出てきません。コードを書くプログラマーがどのように考えているのかを学びましょう。

リーダブルコード

image.png
もちろん、リーダブルコードをおすすめさせてください。この本に関しては、様々な方が紹介をしているので、詳しくは調べてみてください。
命名についての項目などとても有益なことばかりが記載されています。

アルゴリズムとデータ構造

image.png

ゲームを作っていると、Webアプリケーションやアプリ開発に比べて早い段階で、数多くのデータやオブジェクトを管理する、アルゴリズムを構築する
必要に迫られると感じています。
(もちろんWebアプリケーションなどでもありますが、ライブラリがとても有用なため中級者あたりまでは意識することが少ないと感じます)
そのため、どこかでしっかりアルゴリズムやデータ構造を学びたいとなった場合はこちらです。
そして、穴掘り法による迷路やA*によるその解法などを実装してみましょう!

デザインパターン

さて、デザインパターンです。ここまで学習を進めていると、デザインパターンという言葉は様々なところで聞いたことがあるはずです。
デザインパターン自体は、建築家アレグザンダーのパターンランゲージという考え方をモノづくりに応用しようとする試みがあり、
そしてGoFによるオブジェクト指向における再利用のためのデザインパターンに繋がります。
(参考:Wikipedia
デザインパターン自体の歴史が長くなってきていて今のゲーム開発に即時に活用できるかというと難しいのですが、生き残る古典というものにはやはり価値があると思います。

増補改訂版 Java言語で学ぶデザインパターン入門

image.png
日本におけるデザインパターンの入門書としてはもはや右に出るものはないかと思います。
非常にわかりやすくデザインパターンを知ることができます。Javaで説明されていますが、基本の部分はC#とはほぼ一緒なので違和感なく読めます。

Game Programming Patterns ソフトウェア開発の問題解決メニュー

image.png

デザインパターンを実際にゲームに応用するためにはという視点ではこちらの書籍を紹介させてください。
この本の前半では、書籍内でよく参照するデザインパターンのおさらいから始まるので、この本から読んでも頑張れば読み進めることができます。

ゲーム数学、物理

ゲームを創る中では数学と物理が非常に多く出てきます。さらには、より高度なことをしたいと考えたらシェーダーというものを実装することもあるでしょう。
そのようなときのために、ゲームで使われる数学、物理、そしてゲームの動かされ方を知っておくことも有用です。

ゲームアプリの数学 Unityで学ぶ基礎からシェーダーまで

image.png
数学や物理の説明を行っている書籍は様々なので、自分が読みやすいと思ったものを読み進めることが重要です。
この書籍では、実際にUnityを使って試すことができるので、手を動かしたいという方におすすめです。

ゲームを動かす技術と発想R

image.png

ゲーム内のための数学だけではなく、Unityを使っているだけならなかなか触れることがない、
ゲームがそもそもどう動いているのかというより低レイヤー名部分までわかりやすく説明してくれています。

Unityシェーダープログラミングの教科書 ShaderLab言語解説編

※こちらのみ、Boothです。
ゲームづくりをしている中で、演出を深めようとしたら現れる、シェーダーというもの。
シェーダーとはなにか、そしてどの様に創ることができるのかを知ることができます。
image.png

ゲーム演出

Unity ゲームエフェクト マスターガイド

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/153766/d647789c-d6c1-067b-ab19-e56ccef8956e.png

備考:ゲームAI

ゲームAIについても色々と触れられたらいいのですが、まだ著者が挑戦できていないので、2021年にご期待ください。
人工知能について、一般とゲーム関係を1冊づつ。そしてUnityの強化学習についてを紹介するに留めさせてください。

インターネットにもまだまだたくさんの情報が

書籍以外にも様々な情報源があるのが、この令和時代のいいところ!インターネットって素晴らしいですね。
ただ、あまりに情報量が多くなるので、今回は泣く泣く割愛させていただきます。

終わりに

初学者が選ぶべき本は、ここで紹介した書籍である必要はありません。繰り返しますが、
様々な参考書使うより1冊を信じてしっかりやり遂げることが何よりも大事です。
どの書籍も著者の方や編集者の方が考えに考えて構成を考えています。信じて1冊やり遂げてください。

中級者になった方も、もう1ランク上の本を信じてやり遂げましょう。
学び直しは最速のインプットとアウトプットの回転です。考えながらまとめながらやり遂げてください。

そして、上級者になりたいという方は、ゲーム作りの奥深さを受け止めましょう。
どんな旅も一歩一歩歩くことで成し遂げられます。
一緒にゲーム作りという壮大な世界を歩いていきましょう!

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

今PCが手元にないなら絶対に見ないで下さい。記事が優良すぎて、ほぼ100%その場でGraphViewしてしまいます

この記事はUnity #3 Advent Calendar 2020 18日目の記事です

動機

ノベルゲーム最近少なくて寂しいなぁ
データが簡単に作れたらもしかして誰か作るかもしれないなぁ
...せや!シンプルなノードベースのシナリオエディタ作ったろ!

環境

Mac
Unity 2019.4.8.f1

最終的にこんなのを作ります

プロジェクト一式
https://github.com/dwl398/GraphViewSample

どんな人に向けた記事か

・シンプルなノベルシステムを作りたすぎる人

この記事で紹介する内容

・GraphViewの基本的な使い方
・GraphViewで作ったノードの保存、読込(つまづきポイントのみ)

GraphViewの基本的な使い方

1. EditorWindowを作成する

GraphViewを使うためにはまずEditorWindowを用意します

 ScriptGraphView.cs
public class ScriptGraphWindow : EditorWindow
{
    [MenuItem("Tool/ScriptGraph")]
    public static void Open()
    {
        ScriptGraphWindow window = GetWindow<ScriptGraphWindow>();
        window.Show();
    }
}

2. GraphViewを作る

GraphViewを作成します
※UnityEngine.UIElementsに依存した機能が各所に使われているのでusingミスに注意(1敗)

ScriptGraphView.cs
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;

public class ScriptGraphView : GraphView
{
    public ScriptGraphView() : base()
    {
        // 親のサイズに合わせてサイズを設定
        this.StretchToParentSize();
        // ズームインアウト
        SetupZoom(ContentZoomer.DefaultMinScale, ContentZoomer.DefaultMaxScale);
        // ドラッグで描画範囲を移動
        this.AddManipulator(new ContentDragger());
        // ドラッグで選択した要素を移動
        this.AddManipulator(new SelectionDragger());
        // ドラッグで範囲選択
        this.AddManipulator(new RectangleSelector());
    }
}
 ScriptGraphWindow.cs
private void OnEnable()
{
    var scriptGraph = new ScriptGraphView();
    this.rootVisualElement.Add(scriptGraph);
}

これでエディタのメニューからTools/ScriptGraphを選んで開いてみます


なんもでません そりゃそうだ
このまま次のノード作成に行く前にちょっとおしゃれにしたいので寄り道します
エディタの背景が殺風景だと殺風景なシナリオしか出てこないんです
無駄じゃないんです

2_a. 背景を付ける

Resourcesに以下のファイルを追加します

GraphViewBackGround.uss
GridBackground {
    --grid-background-color: #282828;
    --line-color: rgba(193,196,192,0.1);
    --tick-line-color: rgba(193,196,192,0.1);
    --spacing: 20
}![Something went wrong]()

ファイルを読み込んで背景を追加します

ScriptGraphView.cs
public class ScriptGraphView : GraphView
{
    public ScriptGraphView() : base()
    {
        // 省略

        // ussファイルを読み込んでスタイルに追加
        this.styleSheets.Add(Resources.Load<StyleSheet>("GraphViewBackGround"));

        // 背景を一番後ろに追加
        this.Insert(0, new GridBackground());
    }
}

グリッドっぽいものが追加されました

作成したussファイルをいじれば即反映されるのでカスタマイズも簡単です

3. Nodeを作る

次は作成したGraphViewに載せるノードを作ります

MessageNode.cs
using UnityEngine;
using UnityEngine.UIElements;
using UnityEditor.Experimental.GraphView;

public class MessageNode : Node
{
    private TextField textField;

    public MessageNode()
    {
        // ノードのタイトル設定
        this.title = "Message";

        // ポート(後述)を作成
        var inputPort = Port.Create<Edge>(Orientation.Horizontal, Direction.Input, Port.Capacity.Multi, typeof(Port));
        inputPort.portName = "In";
        inputContainer.Add(inputPort);

        var outputOort = Port.Create<Edge>(Orientation.Horizontal, Direction.Output, Port.Capacity.Single, typeof(Port));
        outputOort.portName = "Out";
        outputContainer.Add(outputOort);

        // メイン部分に入力欄追加
        textField = new TextField();
        // 複数行対応
        textField.multiline = true;
        // 日本語入力対応
        textField.RegisterCallback<FocusInEvent>(evt => { Input.imeCompositionMode = IMECompositionMode.On; });
        textField.RegisterCallback<FocusOutEvent>(evt => { Input.imeCompositionMode = IMECompositionMode.Auto; });

        this.mainContainer.Add(textField);
    }
}

これをGraphViewに追加してみます

ScriptGraphView.cs
public ScriptGraphView() : base()
{
    // 省略

    this.Add(new MessageNode());
}

ノードが生成されて配置されました
 

これで全てのノードをプログラムで追加しまくりのハードコーディングしまくりで
色々な賞も受賞しまくりです

4. Nodeをエディタから作れるようにする

もちろん嘘なのでShaderGraphの右クリックで出てくるアレを作ります

ScriptGraphSearchWindowProvider.cs
using System;
using System.Collections.Generic;
using UnityEditor.Experimental.GraphView;
using UnityEngine;

public class ScriptGraphSearchWindowProvider : ScriptableObject, ISearchWindowProvider
{
    private SctiptGraphWindow _window;
    private ScriptGraphView _graphView;

    public void Init(ScriptGraphView graphView,ScriptGraphWindow window)
    {
        _window = window;
        _graphView = graphView;
    }

    public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
    {
        var entries = new List<SearchTreeEntry>();
        entries.Add(new SearchTreeGroupEntry(new GUIContent("Create Node")));

        entries.Add(new SearchTreeEntry(new GUIContent(nameof(MessageNode))) { level = 1, userData = typeof(MessageNode)});

        return entries;
    }

    public bool OnSelectEntry(SearchTreeEntry SearchTreeEntry, SearchWindowContext context)
    {
        var type = SearchTreeEntry.userData as Type;
        var node = Activator.CreateInstance(type) as Node;

        // ノードの生成位置をマウスの座標にする
        var worldMousePosition = _window.rootVisualElement.ChangeCoordinatesTo(_window.rootVisualElement.parent, context.screenMousePosition - _window.position.position);
        var localMousePosition = _graphView.contentViewContainer.WorldToLocal(worldMousePosition);

        node.SetPosition(new Rect(localMousePosition, new Vector2(100, 100)));

        _scriptGraphView.AddElement(node);
        return true;
    }
}

これをScriptGraphView側で生成して設定します

ScriptGraphView.cs
public ScriptGraphView(ScriptGraphWindow window) : base()
{
    // 省略

    // 右クリックでノード作成するウィンドウ追加
    var searchWindowProvider = ScriptableObject.CreateInstance<ScriptGraphSearchWindowProvider>();
    searchWindowProvider.Init(this, window);
    this.nodeCreationRequest += context =>
    {
        SearchWindow.Open(new SearchWindowContext(context.screenMousePosition), searchWindowProvider);
    };
}

これで右クリックでノード生成できるようになりました

ここまでやって受賞しまくりです

4_a. Nodeを作るたびにメニューに追加するのは辛い

entries.Add(new SearchTreeEntry(new GUIContent(nameof(MessageNode))) { level = 1, userData = typeof(MessageNode)});
何度もこんなコードを書くのは辛いので少し楽にします

ScriptGraphNode.cs
public class ScriptGraphNode : Node
{
}
MessageNode.cs
public class MessageNode : ScriptGraphNode
{
    // 省略
}
ScriptGraphSearchWindowProvider.cs
public List<SearchTreeEntry> CreateSearchTree(SearchWindowContext context)
{
    var entries = new List<SearchTreeEntry>();
    entries.Add(new SearchTreeGroupEntry(new GUIContent("Create Node")));

    foreach (var assembly in AppDomain.CurrentDomain.GetAssemblies())
    {
        foreach (var type in assembly.GetTypes())
        {
            if (type.IsClass == false) continue;

            if (type.IsAbstract) continue;

            if (type.IsSubclassOf(typeof(ScriptGraphNode)) == false) continue;

            entries.Add(new SearchTreeEntry(new GUIContent(type.Name)) { level = 1, userData = type });
        }
    }

    return entries;
}

これでScriptGraphNodeを継承したNodeが自動でノード作成メニューに表示されるようになります

5. Nodeを繋ぐ

ノードのついているIn Out のポートを接続します
ポートの接続に関する条件付けができる関数がGraphViewに用意されているので
オーバーライドして条件を記載します

ScriptGraphView.cs
public override List<Port> GetCompatiblePorts(Port startPort, NodeAdapter nodeAdapter)
{
    var compatiblePorts = new List<Port>();

    foreach (var port in ports.ToList())
    {
        // 同じノードは繋げない
        if (startPort.node == port.node) continue;

        // Input - Input , Output - Outputは繋げない
        if (startPort.direction == port.direction) continue;

        // ポートタイプが違うものは繋げない
        if (startPort.portType != port.portType) continue;

        compatiblePorts.Add(port);
    }

    return compatiblePorts;
}

ノードが増えてきた頃に重要になりそうです

GraphViewの基本的な機能はここまでになります
なまじノードの拡張性が高いせいで保存、読み込みなどは各自で用意する必要があります

GraphViewで作ったデータの保存、読込

私のつまづきポイントのみの解説となります
冒頭に記載したgithubにコードがあるので詳細はそちらで確認お願いします

手順としては以下になります

1.保存するデータ(ScriptableObject)を用意
2.編集がScriptableObjectに反映されるように
3.ノードのシリアライザ、デシリアライザを用意
4.ProjectウィンドウのScriptableObject選択でEditorWindowが開くように
5.開いた際にノードが生成されるように
6.ノードをつなぐエッジが保存されるように
7.ノードをつなぐエッジが生成されるように

2.編集がScriptableObjectに反映されるように

保存するデータはこんな感じです

ScriptGraphAsset.cs
[CreateAssetMenu(fileName = "scriptgraph.asset", menuName ="ScriptGraph Asset")]
public class ScriptGraphAsset : ScriptableObject
{
    public List<ScriptNodeData> list = new List<ScriptNodeData>();
}
ScriptNodeData.cs
[Serializable]
public class ScriptNodeData
{
    public int id;

    public NodeType type;

    public Rect rect;

    public int[] outIds;

    public byte[] serialData;
}

ノード作成時にスクリプタブルオブジェクトにデータを追加すればとりあえずの保存はできます
罠です

ScriptGraphSearchWindowProvider.cs
public bool OnSelectEntry(SearchTreeEntry SearchTreeEntry, SearchWindowContext context)
{
    // 省略

    node.SetPosition(new Rect(localMousePosition, new Vector2(100, 100)));

    _scriptGraphView.AddElement(node);

    ScriptGraphData data = Serialize(node);

    // ここで追加しよう!
    _scriptGraphAsset.list.Add(data);

    return true;
}

なぜか、これでは正しいpositionが保存されません

_scriptGtaphView.AddElement()と同じフレームでnodeのpositionを取得しようとすると
Rect(0,0,float.Nan,float.Nan)が帰ってきます

対策としてパッケージマネージャのEditorCoroutineなどでノード作成の次のフレームで保存するように調整しましょう

終わりに

かなりざっくりとですが今graphViewを扱う上で最低限の情報を書きました

実際にこのシステムでノベルゲームを作るとなると拡張が必要になりますが
拡張のためのサンプルもあるので(BranchNode)プログラマの人なら割と誰でも拡張ができると思います

ストーリー、グラフィック、サウンド、さまざまな拡張に対応し、いずれノベルゲームが完成する

そんな未来を信じています

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

UnityWebRequestでAudioのリクエスト時にメモリを大量に消費してしまう問題

UnityWebRequestでAudioのリクエスト時にメモリを大量に消費してしまう問題

記事探していても全く見つからなかったので自分の備忘録として記しておきます.
(当たり前の事過ぎて記事を書く人がいなかった可能性・・・)

//ストリーミングをONにする
((DownloadHandlerAudioClip)request.downloadHandler).streamAudio = true; 
//ロード処理の前にwhileでrequest.isDoneで確認・待機する
while (!request.isNetworkError && !request.isDone) {    
yield return null;
}

この処理をするだけで処理落ちせず,Audioのダウンロードを行う事が出来ます.

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

Maya の MeshSync を少し便利にする

はじめに

これは Maya Advent Calendar 2020 16日目の記事です。

小ネタかつ既知かもしれませんが、MeshSyncの導入や紹介の手引きをする上で気になっていた箇所がありましたのでまとめてみました。文中はかなりUnity色が強いです。ご了承ください。

MeshSync って何?

雑にまとめるとアーティスト作業にフォーカスしたプロセス間通信のUnity版です。アセットのクオリティはイテレーション回数に比例しますが、最近のスマホアプリ製作はUnity側で調整しながらアセット編集を行わずしてクオリティを上げることが出来ない要素が多分に増えたため、MeshSyncのようなリアルタイムプレビュー機能は必須です。

MeshSyncを用いた作業の導線を考えた時、
1. PlayMode中に修正したいオブジェクトを見つける
2. PrefabにあるFBXのパスを調べる
3. Mayaで上記のFBX(ma)を開いて、MeshSync関連コマンドの実行
の3ステップを踏む必要がアセット毎に毎回発生するので、これを1つにまとめたいと考えました。

エディタ拡張スクリプト

MayaのuserSetup.pyのような下準備を不要にしつつ、C#スクリプト1ファイルに機能をまとめてみます。一部ハードコードしている部分がありますので、ご自身の環境に読み替えてください。

ポイントとして以下の条件で処理を分岐させています。

  • Mayaが起動していない
    • コマンドライン引数にMELを渡して起動+更新
  • Mayaが起動している
    • commandportに直接MELを渡して更新

余談として、MeshSyncコンポーネントのプロパティをMayaに送りたいのですが、それらメンバーはinternalです。

アクセスする方法がなくもないのですが、それらを含めるとアドカレ内容の範囲を逸脱してUnityの方に書かないといけなくなりそうなので、ここでは省略します。

using System.Text;
using System.Diagnostics;
using System.Net.Sockets;
using UnityEditor;
using UnityEngine;

public class RemoteControll
{
    public static void Send(dynamic request)
    {
        using (var client = new TcpClient("127.0.0.1", 7001))
        {
            var data = Encoding.UTF8.GetBytes(request);
            using (var stream = client.GetStream())
            {
                stream.Write(data, 0, data.Length);
            }

            client.Dispose();
        }
    }
}

[InitializeOnLoad]
public static class SceneViewCameraTool
{
    static SceneViewCameraTool()
    {
        SceneView.onSceneGUIDelegate += (sceneView) =>
        {
            Handles.BeginGUI();
            using (new GUILayout.VerticalScope(GUI.skin.box, GUILayout.Width(100)))
            {
                GUILayout.Label("Tool");

                if (GUILayout.Button("MeshSync", GUILayout.Width(100)))
                {
                    var app = "/Applications/Autodesk/maya2017/Maya.app/Contents/bin/maya";
                    var port = "if(`commandPort -q \\\":7001\\\"` == false) commandPort -name \\\":7001\\\";";
                    var cmd = "UnityMeshSync_Export;";

                    if (Process.GetProcessesByName("maya").Length > 0)
                    {
                        RemoteControll.Send(
                            cmd +
                            $"viewPlace -p -an true -eye {sceneView.camera.transform.position.x} {sceneView.camera.transform.position.y} {sceneView.camera.transform.position.z} -fov {sceneView.camera.fieldOfView} `lookThru -q`;");
                        return;
                    }

                    var selection = Selection.activeGameObject;
                    if (selection != null)
                    {
                        var meshsync = GameObject.Find("MeshSyncServer");
                        if (meshsync == null)
                        {
                            EditorApplication.ExecuteMenuItem("GameObject/MeshSync/Create Server");
                            Selection.activeGameObject = selection;
                        }

                        var fbx = AssetDatabase.GetAssetPath(
                            PrefabUtility.GetCorrespondingObjectFromOriginalSource(selection));
                        fbx = Application.dataPath.Replace("Assets", "") + fbx;

                        var setup = $"evalDeferred \\\"UnityMeshSync_Settings -p 8080; {cmd}\\\" -lp;";
                        var args = $"-file \"{fbx}\" -hideConsole -nosplash -command \"{port} {setup}\"";

                        var process = new Process
                        {
                            StartInfo =
                            {
                                FileName = app, Arguments = args,
                                WindowStyle = ProcessWindowStyle.Hidden | ProcessWindowStyle.Minimized,
                                CreateNoWindow = true
                            }
                        };
                        process.Start();
                    }
                }
            }

            Handles.EndGUI();
        };
    }
}

普段はデュアルディスプレイで作業されている方も多いと思いますが、キャプチャの都合上1画面で収録しました。SceneView上での拡張に関しては完全に個人的な好みです。

ezgif.com-gif-maker.gif

補足事項

UnityMeshSync_~ コマンドはpluginがロードされた後になるので、evalDeferredで遅延実行します。

viewPlaceコマンドはUnity側の画角を再現してみるために、おまけ程度に使ってみました。

Prefabから直接FBXのパスを参照したりしていますが、実際のところ各社各プロジェクトによって対象を書き換える必要があるかと思います。

おわりに

内製エンジン環境だと(気の利く社内の開発者が作り込んでくれているので)あまり気にしていなかったことも、商用エンジンはそういう痒いところを自分で作って便利にするか、札束で叩いたアセットで解決するしかないです。

細かい処理でもめんどくさがらずにやっておくと、時間短縮になって後が楽になりますね。


TODO

  1. Cameraの同期
  2. Material参照
  3. Component付替
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

UnityでプラットフォームごとにUIの判定エリアを変える

この記事はクラスター Advent Calendar 2020の16日目です。前回はulaphさんによる「SwiftでViewの状態をenumで管理する」でした。C#からSwiftへ軽やかに転身していてカッコいい。

はじめに

こんにちは。クラスター株式会社でデザイナーとして主にUIデザインをやっています。

バーチャルSNS cluster(クラスター)

Unityでマルチプラットフォームに対応したUIを作る場合、同じにできる機構はなるべく共通になるよう努めます。とは言え、無闇矢鱈にすべてを共通化するわけではありませんし、プラットフォームごとの対応が簡単に出来るものは対処しておきたいです。マウスイベントやタップ判定などを行う判定のエリアもその1つです。

プラットフォームごとの判定エリアのサイズ

一般的にマウスカーソルデバイス……ここではPCですが、これのホバーやクリックを判定するエリアのサイズは、UIの見た目と一致していることがほとんどです。

一方スマートフォンでは操作する指よりもUIが小さいため、判定エリアのサイズは見た目以上のサイズになっているのが一般的です。例えばiOSのソフトウェアキーボードは入力する内容を予測して動的に判定エリアのサイズが変化しています。すごい。

参考: ソシオメディア | iPhone の当たり判定を検証した

入力を予測して……はさすがに困難を極めるので、プラットフォームごとでオン/オフする程度の簡単な対処を考えてみました。

Unity UIでボタンを組む

Buttonコンポーネントがアタッチされたオブジェクト以下に背景や画像などのゲームオブジェクトが設置されたヒエラルキーの図

自分がUnityでよく組むタイプのボタンです。Unityのプリセットのようにコンポーネントを1つのオブジェクトにまとめ過ぎず、ビジュアルの責任も適度に分けたほうがデザインの柔軟性があり、かつメンテナンス性も高いです。

ボタンの判定エリアを広げる

判定エリアが広がっていることがわかる実行時のGIFアニメーション

操作時にフィードバックを返すTargetGraphic以下に、Imageコンポーネントをアタッチして、アルファ値を0にしたTargetAreaというゲームオブジェクトを配置しています。このオブジェクトのサイズをボタンよりも広げ、Raycast Targetをオンにすることで判定エリアを広げています。

PCプラットフォームでTargetAreaを削除する

プラットフォームを判別する方法を調べると2つ出てきました。

  1. プラットフォーム依存コンパイル - Unity マニュアル
  2. RuntimePlatform - Unity スクリプトリファレンス

2のRuntimePlatformで以下のよう書かれています。

プラットフォーム別コンパイルを使用するほうが、実行時にチェックする必要がないため軽くて高速なコードを生成できます。

使用しているデバイスが途中で変わる……などということは起きないので、スマートフォン向けに追加したTargetAreaをPCの場合はプラットフォーム依存コンパイルを使って削除するのが簡単そうです。

Unityが用意してくれているUNITY_STANDALONEを使います。囲んだコードが、すべてのPCプラットフォームでコンパイルに含まれます。

DestroysSelfWhenStandalone.cs
using UnityEngine;

public class DestroysSelfWhenStandalone: MonoBehaviour
{
    void Start()
    {
#if UNITY_STANDALONE
        Destroy(this.gameObject);
#endif
    }
}

プラットフォームを切り替えて実行してみる

PCプラットフォーム実行時のみ判定エリアを広げていたゲームオブジェクトが削除されている様子のGIFアニメーション

PCプラットフォームに切り替えて実行した時のみTargetAreaが削除されました。

まとめ

共通化はUnityの大きな利点の1つではありますが、プラットフォームに向けたこういうちょっとした最適化は今後も積み重ねていきたいものです。

心残りが1点。そもそも対象のゲームオブジェクトを環境ごとでビルドに含めない方法を取れれば良かったなと思います(スマートフォン側には虚無のスクリプトが残ることに)。自分で調べた範囲ではその方法は見つけられませんでした。残念。

次回は2tatuki4さんによる「ワールド制作でメモリが大変なことになった時に見るやつを書く」です。お楽しみに!

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

[Unity]Fungusを使ってRPG風会話イベントを作ろう!

この記事はITRC Advent Calendar 2020の16日目の記事です。
前の記事→パケットキャプチャに入門してみる(OSPF編)←ぽっしぶおつおつ~。
次の記事→Neovimについてお話します←蒲生辰巳!NeoVim楽しみ!

こんにちは!@saba383810です。今回は簡単にノベルゲームのような会話イベントが作成できる「Fungus」についてまとめてみました!
ぜひ、楽しみながら自分好みのゲームを作ってみてください!

用意するもの

この記事では以下の素材を使用しますので先にダウンロードしてから本記事をお読みください。

  • お好きなfbx素材(本記事では前回の記事で生成したTda式初音ミク・アペンドVer1.10のfbxを使用します。)
  • 使用したいキャラの立ち絵。(本記事ではゆきぢるしさんの初音ミク立ち絵を使用させてもらいます。)
  • Fungus(記事内でインポートします!)
  • 操作キャラクター(本記事ではUnity-chanを使用します。公式サイトからダウンロードしておいてください。)

Fungusとは

まずは今回使用するアセットについて紹介します。Fungusとは会話イベントを簡単に作成できるUnity Assetでフローチャートを使って視覚的に会話の流れを作成できるAssetです。いわゆるビジュアルスクリプティングなのですが、選択肢立ち絵の表示などのコマンドが豊富によういされているので、Unity初心者でも簡単にノベルゲームや、脱出ゲームなどの会話の部分を作成できます。
fungusは無料で、オープンソース化もされているので使ってみて損はないAssetだと思います。

環境構築

環境構築は簡単です。まずは自分が使用したいプロジェクトが開けたらWindow>AssetStoreでAsset Storeを開きます。
AssetStoreが開けたら、「Fungus」と検索し、以下のAssetをimportしましょう!

image.png

環境構築はこれだけです。わーい!簡単だぁ!


あとは画像の準備をしていきましょう!

Unityで2D画像を使うにはtextureTypeをスプライトに変換しなくてはいけません。
なので、使用したい立ち絵をクリックしてInspectorのTextureTypeをSprite(2D and UI)に変更してApplyをクリックしましょう!
スクリーンショット 2020-12-15 161935.png

Fungusの使い方を知ろう!

Flowchart Objectを作る。

最初はフローチャートのゲームオブジェクトをSceneに追加します。
メニューバーのTools>Fungus>Create>Flowchartをクリックすると、キノコのマークのゲームオブジェクトが生成されます。
スクリーンショット 2020-12-15 163021.png

これが、会話イベントのフローチャートの内容を管理するためのゲームオブジェクトです!

キャラクターの設定ををする。

次に、会話イベントに出てくるキャラクターの立ち絵などの設定をしていきます。
先ほどのflowchartと同じようにTools>Fungus>Createの中のCharacterを選びゲームオブジェクトを生成しましょう!
作成したオブジェクトにはCharacterスクリプトのコンポーネントがアタッチされているので、そこに名前や顔グラフィックを設定します。
image.png
とりあえず、NameTextは「ミク」Sizeは「2」で二つの立ち絵を設定しました。ここは自分の好きなように設定してみましょう!

会話イベントを作る。

会話イベントを作成する準備はできたので、次はフローチャートを編集して会話イベントを作成していきましょう!
FlowChartのキノコのあるゲームオブジェクトを選択し、FlowchartScriptの中のOpen Flowchart Windowをクリックしましょう。
スクリーンショット 2020-12-15 164630.png

するとnewBlockというブロックが置いてあるflowchartの画面が表示されると思います。
新しいブロックを作りたい場合は画面を右クリックでAddBlockから作成できます。

image.png
ブロックをクリックするとInspectorに編集画面が表示されます。

image.png

その中のExecuteOnEventではブロックの開始のタイミングを設定できるのですが、まずはテストのために、Game Started(ゲーム開始時に実行)にしましょう!

また、Animatorと同じように、変数を作成することもできます。変数を使用してスクリプトから制御したい時などに便利ですね!

コマンドを追加して画面に文字表示してみる。

ここまで設定ができたらコマンドをどんどん追加していって会話イベントを作っていくだけです。
Inspector下の「+」からコマンドを使いすることができるので徐に、「Say」と検索してSayコマンドを追加してみましょう!
image.png

するとコマンドの欄にsayが追加されると思うので、sayコマンドをクリックし、Story Textに「Hello , Fungus!」と入力して実行してみてください。

image.png

すると以下のように会話イベントが表示されます。
image.png

キャラクターを表示させてみよう!

次は、キャラクターをsayコマンドで表示させてみましょう!
キャラクターを表示させるにはsayCommandの中のCharacterに表示させたいキャラクターを選択。そして、Chracterの下のPortraitに設定したい立ち絵を設定。

image.png

すると下のように立ち絵が表示されると思います。
image.png
本来なら顔グラを表示してあげたいのですが、素材がないので、立ち絵を表示させてみました。顔グラがある人は顔グラを選択してあげるといいですね!

ここまででFungusのなんとなくの使い方が分かったと思います。つぎは実際にゲーム内でキャラクターに話しかけたときに、会話イベントが表示されるようにしましょう!

3Dゲームに会話イベントを取り込もう!

キャラクターに触れたときに会話イベントを発生させる。

適当にキャラクターを動かせるプロジェクトを作成してください。sabaは前の鑑賞会の
プロジェクトにUnity-chanを入れて以下のように動かせるプロジェクトを用意しました。

Videotogif (4).gif

触れたらイベントが始まるようにする

触れたらFungusの会話イベントが始まるようにして行きます。まずは当たり判定をつけなくてはいけないので、会話イベントを始めたいキャラクターにAddComponentでBoxColliderを追加しましょう!
image.png

Colliderを追加出来たらイベントを発生させたい範囲にEditColliderで大きさを調整しましょう!

image.png

自分は少し大きめに設定してイベントを発生させようと思います。


次にスクリプトを書いていきます。話しかけたいキャラに以下のスクリプトを追加しましょう。

Hit.cs
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class Hit : MonoBehaviour
{
    public Fungus.Flowchart flowchart = null;
    public String sendMessage = "";

    private void OnCollisionEnter(Collision other)
    {
        if (other.gameObject.CompareTag("Player"))
        {
            flowchart.SendFungusMessage(sendMessage);
        }
    }
}

プログラムが書けたら、以下の画像のように、Unity側のHitスクリプトのFlowchartにFLowchartのGameObjectをいれ、SendMessageに自分が呼び出したいFungusのmessageを入力しましょう!

スクリーンショット 2020-12-15 200432.png

次に、動かすキャラクターのタグを「Player」に変更しましょう。

スクリーンショット 2020-12-15 200852.png

最後に、Execute On EventMessage Receivedにし、Messageに呼び出したい値を入力したら完成です。

image.png

(上の画像のようにCallMethodというコマンドを使うと自分が呼び出したいc#スクリプトを呼び出すことができますのでsceneを変えたい場合はぜひ。)

完成したものがこちら

まとめ

お疲れ様です!Fungusを使えば簡単に会話イベントを作成できることが分かったと思います。いろいろなゲームに会話イベントは使われているのでぜひfungus使ってゲーム作りを楽しんでください!本来なら、ゼロプログラミングで記事を書きたかったのですが、自分の知識ではできなかったので急遽変更させてもらいました。最後まで見ていただきありがとうございました。

ライセンス

imageLicenseLogo.png

この作品はユニティちゃんライセンス条項の元に提供されています。

ユニティちゃんは上記のライセンスのもとに提供されています。

参考資料

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