20191211のUnityに関する記事は14件です。

Geometry Shaderのサンプルを解読する(初級編)

はじめに

  • Unityでかっこいいエフェクトを作りたい!!
  • keijiroさんのエフェクトがめっちゃカッコ良いから参考にしたい!!
  • でもテクニックが上級すぎてソース見ても全然わからない!!!!(ごめんなさい)

という経験、Unity勢のみなさんなら一度は通ったことがあるのではないでしょうか…?(暴論)

この記事では、(主に自分が)そんな状態を脱するべく
エフェクトの中でよく使われている Geometry Shader というシェーダーを理解して、
カッコいいエフェクトを作れるようになるための一歩を踏み出します!

※ 備忘録的に書いているので、説明を一部省略してしまっているところがあります。ご了承ください。

Geometry Shader とは

  • 簡単に言うと、プリミティブ(メッシュを構成する基本形状)の増減や変換ができるシェーダーです。
  • Vertex Shader の後、Fragment Shader の前に実行されます。
  • メッシュの頂点数をいじれたり、頂点を増やしてポリゴンにしたり、増やした頂点の位置や回転などを制御することができるため、エフェクトの幅をかなり広げることができます。

以下の記事や本の説明がとてもわかりやすいので、ぜひご一読ください。

サンプル

コード

改変したコードを以下にアップしています。
https://github.com/genkitoyama/StandardGeometryShader

解説

上記のサンプルにおいて、特にGeomrtry Shaderでは大きく3つのことが行われています。

1. 球を構成している三角形の頂点を取得する
2. 取得した三角形の頂点を法線方向に押し出す
3. 押し出した上面と側面のプリミティブを出力する

これらについて、他のシェーダーも合わせて、解説をしていきます。

--

事前準備など

まず事前に、各シェーダに渡す構造体や、CPU側から渡すプロパティなどを設定します。

プロパティ

エフェクトをアニメーションさせるための変数を設定します。
(これをスクリプトやタイムラインからシェーダーに送る)

_LocalTime("Animation Time", Float) = 0.0

構造体 (Struct)

  • Vertex Shader → Geometry Shader に渡すための構造体 Attributes
  • Geometry Shader → Fragment Shader に渡すための構造体 Varyings

の2種類の構造体を用意します。(今回はどちらもpositionnormalのみ)

struct Attributes
{
    float4 position : POSITION;
    float3 normal : NORMAL;
};
struct Varyings
{
    float4 position : SV_POSITION;
    float3 normal : NORMAL;
};

Vertex Shader

Vertex Shaderでは、設定した構造体の各パラメータに関して、
Unityのオブジェクト座標からワールド座標系への変換だけを行います。

Attributes Vertex(Attributes input)
{
    // Only do object space to world space transform.
    input.position = mul(unity_ObjectToWorld, input.position);
    input.normal = UnityObjectToWorldNormal(input.normal);
    return input;
}

Geometry Shader

Geometry Shaderでは、最初に「入出力するプリミティブの型」と、「最大で出力する頂点の数」を設定する必要があります。

今回は、三角形を押し出して三角柱(ただし底面なし)をつくりたいので、

  • 入力のプリミティブの型は triangle で 長さ3の配列
  • 出力のプリミティブの型は TriangleStream
  • 最大で出力する頂点の数 maxvertexcount3 * 1(上面) + 4 * 3(側面) = 15

とします。

また、今回はプリミティブごとにそれぞれ異なった押し出し量にしたいので、
引数に各プリミティブのID uint pid : SV_PrimitiveID も入れています。

[maxvertexcount(15)]
void Geometry(triangle Attributes input[3], uint pid : SV_PrimitiveID, inout TriangleStream<Varyings> outStream)
{
    ...
}

1. 球を構成している三角形の頂点を取得する

三角形で構成されている球(Icosphere)に対して、それぞれの三角形を取得します。
Geometry Shaderはプリミティブ(今回でいうと三角形)ごとに実行されるので、
引数で設定した配列を順番に設定していけばOKです。

float3 wp0 = input[0].position.xyz;
float3 wp1 = input[1].position.xyz;
float3 wp2 = input[2].position.xyz;

このように、Vertex Shaderではできなかった隣接した頂点を参照できるのがGeometry Shaderの強みだったりします。

2.取得した三角形の頂点を法線方向に押し出す

まず、押し出す量を設定します。
saturate() は、引数を[0,1]でクランプして出力してくれる便利関数です。
(0未満の場合は0に、1より大きい時は1に、その間ならそのまま)
※ マジックナンバーに関しては、実際にいじりながら動きを見てみて、気持ち良いところになればOKだと思います。

float ext = saturate(0.4 - cos(_LocalTime * UNITY_PI * 2) * 0.41);
ext *= 1 + 0.3 * sin(pid * 832.37843 + _LocalTime * 88.76);

上記で設定した押し出し量と、 ConstructNormal() という関数で求めた法線ベクトルとを
掛け合わせたものを各頂点に加算することで、押し出し後の頂点を設定していきます。

float3 offs = ConstructNormal(wp0, wp1, wp2) * ext;
float3 wp3 = wp0 + offs;
float3 wp4 = wp1 + offs;
float3 wp5 = wp2 + offs;

3. 押し出した上面と側面のプリミティブを出力する

Geometry Shaderでは、Vertex Shaderで設定した頂点も改めて全て出力し直す必要があります。
そのため、押し出した上面と、押し出したことで出現した側面x3の頂点情報を出力していきます。

出力の仕方はメッシュを作るときと似ていて、

  • outStream.Append() で現在のストリームに頂点情報を出力
  • outStream.RestartStrip() で現在のストリームを終了

を繰り返して出力していきます。

VertexOutput() は、Geometry Shaderの出力用の構造体を用意するための関数です。
具体的には、positionは Fragment Shaderに渡すためにクリッピング座標系に変換し、
normalはそのまま出力しています。

// Cap triangle
float3 wn = ConstructNormal(wp3, wp4, wp5);
float np = saturate(ext * 10);
float3 wn0 = lerp(input[0].normal, wn, np);
float3 wn1 = lerp(input[1].normal, wn, np);
float3 wn2 = lerp(input[2].normal, wn, np);
outStream.Append(VertexOutput(wp3, wn0));
outStream.Append(VertexOutput(wp4, wn1));
outStream.Append(VertexOutput(wp5, wn2));
outStream.RestartStrip();
// Side faces
wn = ConstructNormal(wp3, wp0, wp4);
outStream.Append(VertexOutput(wp3, wn));
outStream.Append(VertexOutput(wp0, wn));
outStream.Append(VertexOutput(wp4, wn));
outStream.Append(VertexOutput(wp1, wn));
outStream.RestartStrip();

Fragment Shader

最後にFragment Shaderで色をつけていきます。

本来はここでDeferred Renderingの処理を書いて質感を高めていますが、
ここでは簡単のためにnormalの値だけRGBに乗算するようにします。
(一色だと動いているかがわかりにくいので)

float4 Fragment(Varyings input) : COLOR
{
    float4 col = _Color;
    col.rgb *= input.normal;
    return col;
}

実行結果

実行すると、こんな感じになります。
タイムラインから送られている _Localtime の値に合わせて、
表面の三角形がボコボコ動くことが確認できます。

out.gif

(最初の状態と色味以外何も変わっていないように見えますが、Geometry Shaderでこのようなエフェクトが作れることがわかったかなと思います…!)

おわりに

  • Geometry Shaderの簡単なサンプルを解読してみました。
    • (初級編と言いつつボリュームが多くてすみません…)
    • (そして勝手に解説してしまってkeijiroさんすみません…)
  • 次回は、中級編としてもっと複雑なGeometry Shaderのエフェクトを解説する予定です!

参考にしたリンク集

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

VRChatで全天球画像を表示する方法

1、概要

完成図はこんな感じになります。
https://youtu.be/DYld7as4qHQ

ワールドココです。
https://www.vrchat.com/home/launch?worldId=wrld_2a3ce568-6345-491d-849b-ec526afbb499&instanceId=61571
申し訳ないですが、まだNewUserなのでフレンドのフレンドまでしか入れません。

記事内に書いてあるライトルームは全て「Lightroom Classic」になります。
ただの「Lightroom」にはパノラマ合成機能は有りません。
adbeのフォトプランに必要な「Lightroom Classic」と「Photoshop」が付いてくるのでお勧めです。
https://www.adobe.com/jp/creativecloud/photography.html

2、写真を撮影する。

2-1、機材

名称付き.jpg
持ち運び最優先の機材になります。
この組み合わせなら三脚が必要なく一脚もコンパクトな為、出張道具の隙間に紛れ込ませることも可能です。
一応パノラマとして繋げられるので十分な撮影は出来ていると思います。
ただし、天頂と真下の撮影が出来ない事、レンズの位置を保持する精度が落ちる事が本物のパノラマヘッドより劣ります。
amazonでは最安5000円くらいからパノラマヘッドが有るようですので
色々と探して自分に合った機材を集めるのが良いと思います。
カメラから購入する必要がある場合はまず「どのレンズを使用するか」を決めてからカメラを選んだ方が色々楽です。
魚眼は特殊なレンズであるためどのカメラでも十分な種類があるとは言えません。
カメラの機種によって選べるレンズが狭まりますので先にレンズを選んだ方が良いです。
センサーがAPS-Cなら8mmの魚眼を選ぶと、良い感じかなと思っています。
魚眼と超広角はまったく別の物ですので注意が必要です。

2-2、カメラのセッティング

・保存形式はRAW
・絞り(F値)、シャッタースピード、ISO感度は全てマニュアル
・半押しでフォーカス、露出を自動調整「しない」ように設定
 特に露出は絶対に固定で行う必要があります。
 撮影途中で露出が大幅に再補正された場合、後処理が出来なくなる事があります。
・フォーカス調整を行うボタンを「半押し以外」のボタンで設定
・露出ブラケット撮影を出来る場合はなるべく広いレンジで露出ブラケットを設定

マニュアル露出調整の指針

一脚の場合、シャッタースピードは1/30くらいが下限になります。
僕のセッティングだと下記くらいを狙うようにしています。
・シャッタースピード最低でも1/30 sec
・F値 8.0
・ISO感度 200

露出調整は下記の順番で行います。
シャッタースピード(最低1/30) > F値 > ISO(最高でも 400未満に抑える)

2ー3、撮影手順

後ろからみた図.jpg
撮影の際に近い構図のカメラ

1、フォーカス、露出関係を合わせる
ピントが合って欲しい辺りに合わせます。
魚眼レンズの場合、ピントが合う範囲が非常に広いためほぼ全域で合うと思います。

露出関係の参考値は下記になります。
・シャッタースピード最低でも1/30 sec
・F値 8.0
・ISO感度 200(最高でも400未満)
必要に応じて左から順に合わせます。
シャッタースピード > F値 > ISO
シャッタースピードでどうにもならなければF値、ISOと触ります。

2、始点の目印になりそうな物を中央へ映す
液晶を見ながら始点の目印になりそうな物を中央へ持ってきます。
この場合は道を中央へ映しています。
中央位置調整.jpg

3、方角を合わせる。
どちらの方角でも構いませんが、コンパスの周囲のダイヤルを回してNの位置を合わせます。
0度、60度、120度、180度(S)、240度、300度、360度(0度)
で撮影をする為、計算がしやすいように合わせておく必要があります。
方角を合わせます。.jpg

4、水平を合わせ、撮影する
水準器で水平を合わせ、合い次第撮影します。
ファインダーや液晶は一切見ません。
撮影時は水準器のみを見て撮影します。

尚、下記は基本的に撮影が終わるまで固定となります。
・シャッタースピード
・F値
・ISO感度
・フォーカス
露出関係は特に動くとヤバいので注意が必要です。

5、方角を合わせる。
0度、60度、120度、180度(S)、240度、300度、360度(0度)
撮影ごとに順番で合わせます。

6、水平を合わせ、撮影する
水準器で水平を合わせ、合い次第撮影します。
ファインダーや液晶は一切見ません。
撮影時は水準器のみを見て撮影します。
2回目以降の撮影では露出、フォーカスの調整は行いません。

7、全周の撮影
5,6を繰り返して全部の角度で撮影します。

注意点
とにかく水平が大事です。
撮影時に水平を保つことに全神経を注いでください。
HAKUBAの水準器は他と比べて値段高めですが、とても重要な部分なので良い物を使った方が良いです。

全周を撮影する場合、どんな状況でも広めのブラケット撮影を推奨します。
連射だったとしても大して画質は下がりませんし、
動体が多くても意外とうまく補正してくれます。
加えて全周の撮影という事は逆光での撮影を強いられることが非常に多く、
大きな明暗差は避けられません。
このためとりあえずブラケット撮影を行うくらいの対応が必要になります。

パノラママウントの制約で天頂と真下は諦めています。

3、画像処理

幾つかの方法がありますが、今回はライトルームを使用しました。
(オカネを掛けれるなら多分PTGuiの方が綺麗に出来るかと思います。
特にPTGui Proに付いてる32bit tiffを作って後からトーンカーブ修正は惹かれるものがあります。)

3-1、LightRoomClassicへ写真を読み込む
LightRoomClassicを起動して「ファイル→写真とビデオを読み込み」を選択します。

image.png

3-2、Shiftを押しながら始点と終点を選択
読み込みが終了し、写真が表示されたら写真を選択します。
撮影の際道を中央へ配置したので、道を目印にします。
最初と最後にほぼ同じ道の絵が有るので、画像で分かると思います。
Shiftを押しながら始点と終点を選択します。
この画面ではIMG_1911~IMG_1920になります。
本来であれば写真7枚、ブラケット撮影を行っていれば21枚の写真を選択する事になります。
(レンズやパノラマヘッドの内容が違うため、写真枚数は画面と一致しません)
最初と最後をほぼ同じ構図にすると分かりやすい.jpg

3-3、パノラマツールで合成します。
「2」で選択した画像を右クリックすると「写真結合→パノラマ」とかがあると思います。

image.png

球面法じゃないと後処理が通らないので注意
image.png

3-4、横長画像をフォトショップで正方形にする。(PhotoshopのScript使用)
このサイトのスクリプトを参考に改造しました。
http://sabaten.com/blog/182/

内部的には
1、元画像の解像度を16384*4096へリサイズ
2、解像度8192*8192の画像を新規作成
3、元画像の左半分8192*4096を新規画像の上半分へ貼り付け
4、元画像の右半分8192*4096を新規画像の下半分へ貼り付け
5、保存
6、全画像を閉じる
を行っています。

この画像を下の2行の画像へ変換します。
この画像を
image.png

この画像のように2行化します。
image.png

下のコードをフォトショの"C:\Program Files\Adobe\Adobe Photoshop 2020\Presets\Scripts"
へ入れると下記みたいな感じで使いやすくなります。
image.png

全天球ファイル変換.jsx
/*
    <javascriptresource>
    <name>天球用ファイルサイズ変換</name>
    <about>天球用ファイルサイズ変換</about>
    </javascriptresource>
*/

//====初期化====
var convertWidth=8192; //横画面の変更後の幅解像度

var jpegQuality=10; //JPEG出力時のクオリティ(0~12)
var outPutPath="D:\\photgra\\pict\\panorama\\熊野座神社\\output\\"; //任意の出力パス

var saveRulerUnits=preferences.rulerUnits; //単位の保存
preferences.rulerUnits=Units.PIXELS; //単位をピクセルに

//====開いている画像の取得====
var baseFile=activeDocument; //現在アクティブなドキュメントを取得
var baseName=baseFile.name.split(".")[0]; //ファイル名の取得

//====保存の設定====
var saveName=baseName+"_ConvedPano.jpg"; //保存ファイル名の設定 
var saveFile=new File(outPutPath+saveName); //保存パス+保存ファイル名 

//====画像のコピー====
var x=baseFile.width; //ベースファイルの横幅を取得
var y=baseFile.height; //ベースファイルの高さを取得
baseFile.selection.selectAll(); //全選択
baseFile.selection.copy(); //選択範囲のコピー


//====新規ドキュメントに貼り付け====
var newFile=documents.add(x , y,baseFile.resolution,"New File"); //新規ファイルの作成
newFile.paste(); //貼り付け
newFile.flatten(); //レイヤー結合


//====サイズの変更====
newFile.resizeImage(convertWidth * 2 ,convertWidth / 2, newFile.resolution , ResampleMethod.AUTOMATIC );

//====変換先ファイルの作成====
var convertedFile = documents.add(convertWidth , convertWidth ,baseFile.resolution,"New File"); //新規ファイルの作成

//====変換元ファイルからコピー====
app.activeDocument = newFile;
var regionUp= [ [0, 0], [0, convertWidth / 2 ], [convertWidth, convertWidth / 2 ] , [convertWidth, 0] ];
newFile.selection.select(regionUp, SelectionType.REPLACE, 0, false);
newFile.selection.copy(); //選択範囲のコピー

//====変換先へペースト====
app.activeDocument = convertedFile;
var pasteRegion1 = [ [0, 0], [0, convertWidth / 2 ], [convertWidth, convertWidth / 2 ] , [convertWidth, 0] ];
convertedFile.selection.select(pasteRegion1, SelectionType.REPLACE, 0, false);
convertedFile.paste( true );

//====変換元ファイルからコピー====
app.activeDocument = newFile;
var regionBottom= [ [convertWidth, 0], [convertWidth, convertWidth / 2 ], 
    [convertWidth * 2, convertWidth / 2] , [convertWidth * 2 , 0] ];

newFile.selection.select(regionBottom, SelectionType.REPLACE, 0, false);
newFile.selection.copy(); //選択範囲のコピー

//====変換先へペースト====
app.activeDocument = convertedFile;
var pasteRegion2 = [ [0, convertWidth / 2], [0, convertWidth ], [convertWidth , convertWidth  ] , [convertWidth , convertWidth / 2] ];
convertedFile.selection.select(pasteRegion2, SelectionType.REPLACE, 0, false);
convertedFile.paste( true );
convertedFile.flatten(); //レイヤー結合

//====JPEGで保存====
var jpegSaveOpt=new JPEGSaveOptions(); //JPEG保存設定
jpegSaveOpt.quality=jpegQuality; //JPEGクオリティ
jpegSaveOpt.embedColorProfile=false;
jpegSaveOpt.formatOptions=FormatOptions.OPTIMIZEDBASELINE;

convertedFile.saveAs(saveFile,jpegSaveOpt,true,Extension.LOWERCASE);

//====新規ドキュメントを閉じる====
baseFile.close(SaveOptions.DONOTSAVECHANGES);
baseFile=null;

newFile.close(SaveOptions.DONOTSAVECHANGES);
newFile=null;

convertedFile.close(SaveOptions.DONOTSAVECHANGES);
convertedFile=null;

//====元ファイルを閉じる====
//baseFile.close(SaveOptions.DONOTSAVECHANGES);
//baseFile=null;

preferences.rulerUnits=saveRulerUnits; //単位を元に戻す

4、全天球シェーダーを作る。

ここのコードを参考に作成しました。
https://stackoverflow.com/questions/37088286/360-viewer-in-unity-texture-appears-warped-in-the-top-and-bottom

SphealImage.shader
Shader "Unlit/SphealImage"
{
    Properties{
        _Color("Main Color", Color) = (1,1,1,1)                 //何もない所の色
        _MainTex("Diffuse (RGB) Alpha (A)", 2D) = "gray" {}     //全天球画像
        _PitchMin("PitchMin" , Range(0.0, 1.0)) = 0.1           //上の表示範囲
        _PitchMax("PitchMax" , Range(0.0, 1.0)) = 0.9           //下の表示範囲
        _YawMin("YawMin" , Range(0.0, 1.0)) = 0.0               //写真の横幅
        _YawMax("YawMax" , Range(0.0, 1.0)) = 1.0               //写真の横幅
        _Alpha("Opacity" , Range(0.0, 1.0)) = 1.0               //何もない場所の不透明度
        _NoiseTex("Noize", 2D) = "Noise" {}                     //消えるときに使用するノイズ画像
        _NoiseLoopU("NoiseLoopU" ,float ) = 8                   //ノイズ画像の繰り返し数
        _NoiseLoopV("NoiseLoopV" , float) = 2                   //ノイズ画像の繰り返し数
    }

        SubShader{
            Tags {  "Queue" = "AlphaTest+10 " }
            Pass {

                //Cull Front
                //ZWrite Off
                ZTest Always
                //Blend SrcAlpha OneMinusSrcAlpha

                CGPROGRAM
                    #pragma vertex vert
                    #pragma fragment frag
                    //#pragma fragmentoption ARB_precision_hint_fastest
                    //#pragma glsl
                    //#pragma target 3.0

                    #include "UnityCG.cginc"

                    struct appdata {
                       float4 vertex : POSITION;
                       float3 normal : NORMAL;
                    };

                    struct v2f
                    {
                        float4    pos : SV_POSITION;
                        float3    normal : TEXCOORD0;
                    };

                    sampler2D _NoiseTex;
                    float _NoiseLoopU;
                    float _NoiseLoopV;

                    v2f vert(appdata v)
                    {
                        v2f o;
                        o.pos = UnityObjectToClipPos(v.vertex);
                        o.normal = v.normal;

                        return o;
                    }

                    sampler2D _MainTex;
                    float _PitchMin;
                    float _PitchMax;
                    float _YawMin;
                    float _YawMax;
                    float4 _Color;
                    float _Alpha;

                    #define PI 3.141592653589793

                    //法線の向きをテクスチャ座標へ変換
                    inline float2 RadialCoords(float3 a_coords)
                    {
                        float3 a_coords_n = normalize(a_coords);
                        float lon = 0.0;

                        lon = atan2(a_coords_n.z, a_coords_n.x);
                        float lat = acos(a_coords_n.y);
                        float2 sphereCoords = float2(lon, lat) * (1.0 / PI);
                        return float2(1.0 - (sphereCoords.x * 0.5 + 0.5),  sphereCoords.y);
                    }

                    //テクスチャ座標を2行テクスチャ
                    inline float2 RdAdjust(float2 inCord )
                    {
                        float2 res;
                        res.x = (inCord.x - _YawMin) / (_YawMax - _YawMin);
                        res.y = (inCord.y - _PitchMin) / (_PitchMax - _PitchMin);
                        res.x *= 2.0;
                        res.y *= 0.5;

                        if (res.x > 1.0)
                        {
                            res.x -= 1.0;
                            res.y += 0.5;
                        }

                        return res;
                    }

                    //ピクセルシェーダ
                    float4 frag(v2f IN) : COLOR
                    {
                        //法線をテクスチャ座標化
                        float2 equiUV = RadialCoords(IN.normal);

                        //指定範囲内かのチェック
                        if (equiUV.x >= _YawMin && equiUV.x <= _YawMax &&
                            equiUV.y >= _PitchMin && equiUV.y <= _PitchMax )
                        {

                            float4 res = tex2D(_MainTex, RdAdjust(equiUV ));
                            res.a = 1.0;

                            //ノイズ用UV作成、
                            float2 noiseTexUv = float2(equiUV.x * _NoiseLoopU, equiUV.y * _NoiseLoopV);
                            float texAlpha = tex2D(_NoiseTex, RdAdjust(noiseTexUv));
                            clip(_Alpha - texAlpha);

                            return res;
                        }

                        float4 res = _Color;
                        res.a = _Alpha;
                        return _Color;


                    }
                ENDCG
            }
        }
    FallBack "VertexLit"
}

後はメタセコかなんかで面を反転させた球を作るか、
UNITYの標準の球に「//Cull Front」の行を有効化するかします。

下図はメタセコで面を反転した球を作ってる図です。
image.png

後はUNITYへ組み込めば表示されます。
RenderQueueはシェーダーへ組み込んだものの方が優先されます。
マテリアルの設定ではなくシェーダーで必要に応じて調整してください。
今回作成したワールドでは表示用のエフェクトの都合でレンダリング順をかなり後の方にしています。

エディター画面上ではマテリアル設定が優先されるので、編集の都合に合わせて設定してください。

image.png

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

GameLift RealTimeServerで遊んでみよう for Unity(Unity編)

はじめに

前回からのつづき

前回はこちら
GameLift RealTimeServerで遊んでみよう for Unity(AWS設定編)

対象者

  • AWSのアカウントを持っている方
  • GameLiftでとりあえず遊んでみたいと考えているUnityエンジニアの方
  • AWSの無料利用枠がまだある方、もしくは使用金額を払ってでもやりたい方(この辺りは自己責任で)
  • 今回作成するものをきちんとクリーンアップできる方

試した環境

  • MacBook Pro (13-inch, 2017)
  • OSバージョン10.14.6
  • Unity2019.2.15f

このページで行うこと

Unity設定をし、RealTimeServerでのデータの送受信まで。
「誰かが送ったメッセージをトリガーにし、UDPでつながっているすべてのユーザーにデータを送る」
ことをやってみる。

Unityでの設定

  • パッケージのインストール

Unityでの実装

  • AmazonGameLiftClientクラスの初期化をする
  • ルームを作成
  • ルームを検索
  • ルームへの参加
  • データ送受信を確認

パッケージのインストール

AWS.NET SDK

AWSSDK.Core(3.3.104を使用)
AWSSDK.GameLift(3.3.104.18を使用)

ともにこちらからダウンロード。
(自分はNuGetの方を選択)
.Net4.5対応のものを使ってください。

GameLift Realtime Client SDK

こちらのRealtime Client SDKをダウンロードしVisualStudioなどでビルド

上記で入手したライブラリをUnityのPluginsフォルダ以下に配置
スクリーンショット 2019-12-08 15.27.35.png

UnityのApi Compatibility Levelを.Net 4.xにするのを忘れないように
スクリーンショット 2019-12-08 15.50.04.png
Build Setting -> Player Settings -> Player -> Configuration -> Other Settings -> API Compatibility Level

今回説明するスクリプト

主要となるスクリプトを載せております。
UIに関しては各々で実装していただければ。

クライアント側

こちらのサンプルを元に作成

Lobby.cs
using System;
using System.Linq;
using System.Diagnostics;
using System.Collections.Generic;
using System.Net.NetworkInformation;
using UnityEngine;
using Amazon;
using Amazon.GameLift;
using Amazon.GameLift.Model;
using Aws.GameLift.Realtime.Types;
public class Lobby : MonoBehaviour
{
    class GameLiftConfig
    {
        public RegionEndpoint RegionEndPoint { get; set; }
        public string AccessKeyId { get; set; }
        public string SecretAccessKey { get; set; }
        public string GameLiftAliasId { get; set; }
    }
    GameLiftConfig config;
    AmazonGameLiftClient gameLiftClient;
    RealTimeClient realTimeClient;

    [SerializeField]
    LobbyUI ui;
    // Start is called before the first frame update
    void Start()
    {
        UnityEngine.Debug.Log("start");
        initialize();
    }

    void initialize()
    {
        config = new GameLiftConfig
        {
            RegionEndPoint = RegionEndpoint.APNortheast1, //東京の場合
            AccessKeyId = "", // ダウンロードしたcsvのAccess key IDの値
            SecretAccessKey = "", // ダウンロードしたcsvのSecret access keyの値
            GameLiftAliasId = "" // 作成したAliasのID alias- から始まるID
        };

        // AmazonGameLiftClientクラスの初期化
        gameLiftClient = new AmazonGameLiftClient(config.AccessKeyId, config.SecretAccessKey, config.RegionEndPoint);

        ui.CreateRoomButton.onClick.AddListener(() =>
        {
            CreateRoom();
        });

        ui.SearchRoomButton.onClick.AddListener(() =>
       {
           var sessions = SearchRooms();
           ui.ClearAllPanels();
           ui.CreateSessionPanels(sessions, JoinRoom);
       });

        ui.SendTest1Button.onClick.AddListener(() =>
        {
            if (realTimeClient != null) realTimeClient.SendMessage(DeliveryIntent.Reliable, "test");
        });
        ui.SendTest2Button.onClick.AddListener(() =>
        {
            if (realTimeClient != null) realTimeClient.SendEvent(RealTimeClient.OpCode.SendTest2);
        });
    }

    // ルームの作成
    void CreateRoom(string roomName = "")
    {
        UnityEngine.Debug.Log("CreateRoom");
        if (string.IsNullOrEmpty(roomName)) roomName = Guid.NewGuid().ToString();
        var request = new CreateGameSessionRequest
        {
            AliasId = config.GameLiftAliasId,
            MaximumPlayerSessionCount = 2,
            Name = roomName
        };
        var response = gameLiftClient.CreateGameSession(request);
        ui.InfoText.text += "CreateRoom\n";
    }

    //ルームの検索
    public List<GameSession> SearchRooms()
    {
        UnityEngine.Debug.Log("SearchRooms");
        var response = gameLiftClient.SearchGameSessions(new SearchGameSessionsRequest
        {
            AliasId = config.GameLiftAliasId,
        });
        ui.InfoText.text += "SearchRoom\n";
        return response.GameSessions;
    }

    // ルームへの参加
    void JoinRoom(string sessionId)
    {
        UnityEngine.Debug.Log("JoinRoom");
        var response = gameLiftClient.CreatePlayerSession(new CreatePlayerSessionRequest
        {
            GameSessionId = sessionId,
            PlayerId = SystemInfo.deviceUniqueIdentifier,
        });
        var playerSession = response.PlayerSession;

        ushort DefaultUdpPort = 7777;
        var udpPort = SearchAvailableUdpPort(DefaultUdpPort, DefaultUdpPort + 100);
        realTimeClient = new RealTimeClient(
            playerSession.IpAddress,
            playerSession.Port,
            udpPort,
            ConnectionType.RT_OVER_WS_UDP_UNSECURED,
            playerSession.PlayerSessionId,
            null);

        ui.InfoText.text += "JoinRoom\n";
        realTimeClient.OnDataReceivedCallback = OnDataReceivedCallback;
    }

    public void OnDataReceivedCallback(object sender, Aws.GameLift.Realtime.Event.DataReceivedEventArgs e)
    {
        if (ui.InfoText != null)
        {
            ui.InfoText.text += $"{e.OpCode}\n";
        }
    }

    int SearchAvailableUdpPort(int from = 1024, int to = ushort.MaxValue)
    {
        from = Mathf.Clamp(from, 1, ushort.MaxValue);
        to = Mathf.Clamp(to, 1, ushort.MaxValue);
#if UNITY_STANDALONE_OSX || UNITY_EDITOR_OSX
        var set = LsofUdpPorts(from, to);
#else
        var set = GetActiveUdpPorts();
#endif
        for (int port = from; port <= to; port++)
            if (!set.Contains(port))
                return port;
        return -1;
    }

    HashSet<int> LsofUdpPorts(int from, int to)
    {
        var set = new HashSet<int>();
        string command = string.Join(" | ",
            $"lsof -nP -iUDP:{from.ToString()}-{to.ToString()}",
            "sed -E 's/->[0-9.:]+$//g'",
            @"grep -Eo '\d+$'");
        var process = Process.Start(new ProcessStartInfo
        {
            FileName = "/bin/bash",
            Arguments = $"-c \"{command}\"",
            RedirectStandardOutput = true,
            UseShellExecute = false,
        });
        if (process != null)
        {
            process.WaitForExit();
            var stream = process.StandardOutput;
            while (!stream.EndOfStream)
                if (int.TryParse(stream.ReadLine(), out int port))
                    set.Add(port);
        }
        return set;
    }

    HashSet<int> GetActiveUdpPorts()
    {
        return new HashSet<int>(IPGlobalProperties.GetIPGlobalProperties()
            .GetActiveUdpListeners().Select(listener => listener.Port));
    }
}



RealTimeClient.cs
using System;
using System.Text;
using Aws.GameLift.Realtime;
using Aws.GameLift.Realtime.Event;
using Aws.GameLift.Realtime.Types;
public class RealTimeClient
{
    public Aws.GameLift.Realtime.Client Client { get; private set; }

    public Action<object, DataReceivedEventArgs> OnDataReceivedCallback { get; set; }
    // An opcode defined by client and your server script that represents a custom message type
    public static class OpCode
    {
        public const int SendTest1 = 10;
        public const int SendTest2 = 11;

        public const int RecieveTest1 = 31;
        public const int RecieveTest2 = 32;

    }

    /// Initialize a client for GameLift Realtime and connect to a player session.
    /// <param name="endpoint">The DNS name that is assigned to Realtime server</param>
    /// <param name="remoteTcpPort">A TCP port for the Realtime server</param>
    /// <param name="listeningUdpPort">A local port for listening to UDP traffic</param>
    /// <param name="connectionType">Type of connection to establish between client and the Realtime server</param>
    /// <param name="playerSessionId">The player session ID that is assigned to the game client for a game session </param>
    /// <param name="connectionPayload">Developer-defined data to be used during client connection, such as for player authentication</param>
    public RealTimeClient(string endpoint, int remoteTcpPort, int listeningUdpPort, ConnectionType connectionType,
                 string playerSessionId, byte[] connectionPayload)
    {
        // Create a client configuration to specify a secure or unsecure connection type
        // Best practice is to set up a secure connection using the connection type RT_OVER_WSS_DTLS_TLS12.
        ClientConfiguration clientConfiguration = new ClientConfiguration()
        {
            // C# notation to set the field ConnectionType in the new instance of ClientConfiguration
            ConnectionType = connectionType
        };

        // Create a Realtime client with the client configuration            
        Client = new Client(clientConfiguration);

        // Initialize event handlers for the Realtime client
        Client.ConnectionOpen += OnOpenEvent;
        Client.ConnectionClose += OnCloseEvent;
        Client.GroupMembershipUpdated += OnGroupMembershipUpdate;
        Client.DataReceived += OnDataReceived;

        // Create a connection token to authenticate the client with the Realtime server
        // Player session IDs can be retrieved using AWS SDK for GameLift
        ConnectionToken connectionToken = new ConnectionToken(playerSessionId, connectionPayload);

        // Initiate a connection with the Realtime server with the given connection information
        Client.Connect(endpoint, remoteTcpPort, listeningUdpPort, connectionToken);
    }

    public void Disconnect()
    {
        if (Client.Connected)
        {
            Client.Disconnect();
        }
    }

    public bool IsConnected()
    {
        return Client.Connected;
    }

    /// <summary>
    /// Example of sending to a custom message to the server.
    /// 
    /// Server could be replaced by known peer Id etc.
    /// </summary>
    /// <param name="intent">Choice of delivery intent ie Reliable, Fast etc. </param>
    /// <param name="payload">Custom payload to send with message</param>
    public void SendMessage(DeliveryIntent intent, string payload)
    {
        UnityEngine.Debug.Log("SendMessage");
        Client.SendMessage(Client.NewMessage(OpCode.SendTest1)
            .WithDeliveryIntent(intent)
            .WithTargetPlayer(Constants.PLAYER_ID_SERVER)
            .WithPayload(StringToBytes(payload)));
    }

    /**
     * Handle connection open events
     */
    public void OnOpenEvent(object sender, EventArgs e)
    {
        UnityEngine.Debug.Log("OnOpenEvent");
    }

    /**
     * Handle connection close events
     */
    public void OnCloseEvent(object sender, EventArgs e)
    {
        UnityEngine.Debug.Log("OnCloseEvent");
    }

    /**
     * Handle Group membership update events 
     */
    public void OnGroupMembershipUpdate(object sender, GroupMembershipEventArgs e)
    {
        UnityEngine.Debug.Log("OnGroupMembershipUpdate");
    }

    /**
     *  Handle data received from the Realtime server 
     */
    public virtual void OnDataReceived(object sender, DataReceivedEventArgs e)
    {
        UnityEngine.Debug.Log("OnDataReceived");
        UnityEngine.Debug.Log($"OpCode = {e.OpCode}");
        switch (e.OpCode)
        {
            // handle message based on OpCode
            default:
                break;
        }

        if (OnDataReceivedCallback != null) OnDataReceivedCallback(sender, e);
    }

    /**
     * Helper method to simplify task of sending/receiving payloads.
     */
    public static byte[] StringToBytes(string str)
    {
        return Encoding.UTF8.GetBytes(str);
    }

    /**
     * Helper method to simplify task of sending/receiving payloads.
     */
    public static string BytesToString(byte[] bytes)
    {
        return Encoding.UTF8.GetString(bytes);
    }

    public void SendEvent(int opCode)
    {
        UnityEngine.Debug.Log("SendEvent");
        if (!IsConnected()) return;
        Client.SendEvent(opCode);
    }
}



リファレンスはこちら

サーバー側

こちらのサンプルを元に作成

server.js
'use strict';
const util = require('util');

const tickTime = 1000;
const minimumElapsedTime = 120;

const SendTest1 = 10;
const SendTest2 = 11;

const RecieveTest1 = 31;
const RecieveTest2 = 32;

// The Realtime server session object
var session;

var logger;
var activePlayers = 0;              // Records the number of connected players

var startTime;                      // Records the time the process started

function init(rtSession) {
    session = rtSession;
    logger = session.getLogger();

}


// A simple tick loop example
// Checks to see if a minimum amount of time has passed before seeing if the game has ended
async function tickLoop() {
    const elapsedTime = getTimeInS() - startTime;
    logger.info("Tick... " + elapsedTime + " activePlayers: " + activePlayers);

    // In Tick loop - see if all players have left early after a minimum period of time has passed
    // Call processEnding() to terminate the process and quit
    if ((activePlayers == 0) && (elapsedTime > minimumElapsedTime)) {
        logger.info("All players disconnected. Ending game");
        const outcome = await session.processEnding();
        logger.info("Completed process ending with: " + outcome);
        process.exit(0);
    }
    else {
        setTimeout(tickLoop, tickTime);
    }
}

// Calculates the current time in seconds
function getTimeInS() {
    return Math.round(new Date().getTime() / 1000);
}

function onProcessStarted(args) {
    logger.info(`[onProcessStarted]`);
    return true;
}
function onStartGameSession(gameSession) {
    // Complete any game session set-up
    logger.info(`[onStartGameSession]`);
    // tryDelayExit();
    startTime = getTimeInS();
    tickLoop();
}

// Handle process termination if the process is being terminated by GameLift
// You do not need to call ProcessEnding here
function onProcessTerminate() {
    // Perform any clean up
}

// On Player Connect is called when a player has passed initial validation
// Return true if player should connect, false to reject
function onPlayerConnect(connectMsg) {
    logger.info(`[onPlayerConnect]`);
    return true;
}

// Called when a Player is accepted into the game
function onPlayerAccepted(player) {
    logger.info(`[onPlayerAccepted]`);
    activePlayers++;
}

// On Player Disconnect is called when a player has left or been forcibly terminated
// Is only called for players that actually connected to the server and not those rejected by validation
// This is called before the player is removed from the player list
function onPlayerDisconnect(peerId) {
    logger.info(`[onPlayerDisconnect]`);
    activePlayers--;
    // tryDelayExit();
}

// Return true if the player is allowed to join the group
function onPlayerJoinGroup(groupId, peerId) {
    return true;
}

// Return true if the player is allowed to leave the group
function onPlayerLeaveGroup(groupId, peerId) {
    return true;
}

// Return true if the send should be allowed
function onSendToPlayer(gameMessage) {
    return true;
}

// Return true if the send to group should be allowed
// Use gameMessage.getPayloadAsText() to get the message contents
function onSendToGroup(gameMessage) {
    logger.info(`[onSendToGroup]`);

    return true;
}

// Handle a message to the server
function onMessage(gameMessage) {
    logger.info(`[onMessage]`);
    switch (gameMessage.opCode) {
        case SendTest1: {
            // do operation 1 with gameMessage.payload for example sendToGroup
            const outMessage = session.newTextGameMessage(RecieveTest1, session.getServerId(), gameMessage.payload);
            session.sendGroupMessage(outMessage, -1);
            break;
        }
        case SendTest2: {
            // do operation 1 with gameMessage.payload for example sendToGroup
            const outMessage = session.newTextGameMessage(RecieveTest2, session.getServerId(), gameMessage.payload);
            session.sendGroupMessage(outMessage, -1);
            break;
        }
    }
}

// Return true if the process is healthy
function onHealthCheck() {
    return true;
}

exports.ssExports = {
    init: init,
    onProcessStarted: onProcessStarted,
    onStartGameSession: onStartGameSession,
    onProcessTerminate: onProcessTerminate,
    onPlayerConnect: onPlayerConnect,
    onPlayerAccepted: onPlayerAccepted,
    onPlayerDisconnect: onPlayerDisconnect,
    onPlayerJoinGroup: onPlayerJoinGroup,
    onPlayerLeaveGroup: onPlayerLeaveGroup,
    onSendToPlayer: onSendToPlayer,
    onSendToGroup: onSendToGroup,
    onMessage: onMessage,
    onHealthCheck: onHealthCheck
};


リファレンスはこちら
前回作成したものに追記いただければ。
server.jsを更新する際はGameLiftのスクリプトを編集しアップロードしてください

スクリーンショット 2019-12-11 18.34.15.png

AmazonGameLiftClientクラスの初期化をする

Lobby.cs
         config = new GameLiftConfig
        {
            RegionEndPoint = RegionEndpoint.APNortheast1, //東京の場合
            AccessKeyId = "", // ダウンロードしたcsvのAccess key IDの値
            SecretAccessKey = "", // ダウンロードしたcsvのSecret access keyの値
            GameLiftAliasId = "" // 作成したAliasのID alias- から始まるID
        };

        // AmazonGameLiftClientクラスの初期化
        gameLiftClient = new AmazonGameLiftClient(config.AccessKeyId, config.SecretAccessKey, config.RegionEndPoint);
引っかかりやすいポイント

AmazonGameLiftClientクラスの初期化に使用する値を間違えないように
※リリースするようなものであれば、コードに直に書いたりはしないよう注意

ルームの作成

Lobby.cs
    // ルームの作成
    void CreateRoom(string roomName = "")
    {
        UnityEngine.Debug.Log("CreateRoom");
        if (string.IsNullOrEmpty(roomName)) roomName = Guid.NewGuid().ToString();
        var request = new CreateGameSessionRequest
        {
            AliasId = config.GameLiftAliasId,
            MaximumPlayerSessionCount = 2,
            Name = roomName
        };
        var response = gameLiftClient.CreateGameSession(request);
        ui.InfoText.text += "CreateRoom\n";
    }

CreateGameSessionRequestを作成しCreateGameSessionに渡せば簡単に作成できる。

ルームの検索

Lobby.cs
    //ルームの検索
    public List<GameSession> SearchRooms()
    {
        UnityEngine.Debug.Log("SearchRooms");
        var response = gameLiftClient.SearchGameSessions(new SearchGameSessionsRequest
        {
            AliasId = config.GameLiftAliasId,
        });
        ui.InfoText.text += "SearchRoom\n";
        return response.GameSessions;
    }

SearchGameSessionsRequestをAliasIdを引数とし作成し
SearchGameSessionsに渡せば簡単に検索できる。

ルームへの参加

Lobby.cs
    // ルームへの参加
    void JoinRoom(string sessionId)
    {
        UnityEngine.Debug.Log("JoinRoom");
        var response = gameLiftClient.CreatePlayerSession(new CreatePlayerSessionRequest
        {
            GameSessionId = sessionId,
            PlayerId = SystemInfo.deviceUniqueIdentifier,
        });
        var playerSession = response.PlayerSession;

        ushort DefaultUdpPort = 7777;
        var udpPort = SearchAvailableUdpPort(DefaultUdpPort, DefaultUdpPort + 100);
        realTimeClient = new RealTimeClient(
            playerSession.IpAddress,
            playerSession.Port,
            udpPort,
            ConnectionType.RT_OVER_WS_UDP_UNSECURED,
            playerSession.PlayerSessionId,
            null);

        ui.InfoText.text += "JoinRoom\n";
        realTimeClient.OnDataReceivedCallback = OnDataReceivedCallback;
    }

RealTimeClient.cs
   /// Initialize a client for GameLift Realtime and connect to a player session.
    /// <param name="endpoint">The DNS name that is assigned to Realtime server</param>
    /// <param name="remoteTcpPort">A TCP port for the Realtime server</param>
    /// <param name="listeningUdpPort">A local port for listening to UDP traffic</param>
    /// <param name="connectionType">Type of connection to establish between client and the Realtime server</param>
    /// <param name="playerSessionId">The player session ID that is assigned to the game client for a game session </param>
    /// <param name="connectionPayload">Developer-defined data to be used during client connection, such as for player authentication</param>
    public RealTimeClient(string endpoint, int remoteTcpPort, int listeningUdpPort, ConnectionType connectionType,
                 string playerSessionId, byte[] connectionPayload)
    {
        // Create a client configuration to specify a secure or unsecure connection type
        // Best practice is to set up a secure connection using the connection type RT_OVER_WSS_DTLS_TLS12.
        ClientConfiguration clientConfiguration = new ClientConfiguration()
        {
            // C# notation to set the field ConnectionType in the new instance of ClientConfiguration
            ConnectionType = connectionType
        };

        // Create a Realtime client with the client configuration            
        Client = new Client(clientConfiguration);

        // Initialize event handlers for the Realtime client
        Client.ConnectionOpen += OnOpenEvent;
        Client.ConnectionClose += OnCloseEvent;
        Client.GroupMembershipUpdated += OnGroupMembershipUpdate;
        Client.DataReceived += OnDataReceived;

        // Create a connection token to authenticate the client with the Realtime server
        // Player session IDs can be retrieved using AWS SDK for GameLift
        ConnectionToken connectionToken = new ConnectionToken(playerSessionId, connectionPayload);

        // Initiate a connection with the Realtime server with the given connection information
        Client.Connect(endpoint, remoteTcpPort, listeningUdpPort, connectionToken);
    }

Clientで新しいクライアントを初期化し
Client.Connectでゲームセッションをホストしているサーバープロセスへの接続をリクエストしルームへ参加する。
MacだとIPGlobalProperties.GetIPGlobalProperties().GetActiveUdpListeners()
でうまく有効なUDPポートとれなかったので、別実装で取得

データ送受信の確認

送るイベントのクライアント側定義

RealTimeClient.cs
        public const int SendTest1 = 10;
        public const int SendTest2 = 11;

        public const int RecieveTest1 = 31;
        public const int RecieveTest2 = 32

送るイベントのサーバー側定義

server.js
const SendTest1 = 10;
const SendTest2 = 11;

const RecieveTest1 = 31;
const RecieveTest2 = 32

  • イベントやメッセージの送信処理
RealTimeClient.cs
    /// <summary>
    /// Example of sending to a custom message to the server.
    /// 
    /// Server could be replaced by known peer Id etc.
    /// </summary>
    /// <param name="intent">Choice of delivery intent ie Reliable, Fast etc. </param>
    /// <param name="payload">Custom payload to send with message</param>
    public void SendMessage(DeliveryIntent intent, string payload)
    {
        UnityEngine.Debug.Log("SendMessage");
        Client.SendMessage(Client.NewMessage(OpCode.SendTest1)
            .WithDeliveryIntent(intent)
            .WithTargetPlayer(Constants.PLAYER_ID_SERVER)
            .WithPayload(StringToBytes(payload)));
    }

    public void SendEvent(int opCode)
    {
        UnityEngine.Debug.Log("SendEvent");
        if (!IsConnected()) return;
        Client.SendEvent(opCode);
    }

SendMessageとSendEventで試してみる。

  • サーバー側ハンドリング処理
server.js
// Handle a message to the server
function onMessage(gameMessage) {
    logger.info(`[onMessage]`);
    switch (gameMessage.opCode) {
        case SendTest1: {
            // do operation 1 with gameMessage.payload for example sendToGroup
            const outMessage = session.newTextGameMessage(RecieveTest1, session.getServerId(), gameMessage.payload);
            session.sendGroupMessage(outMessage, -1);
            break;
        }
        case SendTest2: {
            // do operation 1 with gameMessage.payload for example sendToGroup
            const outMessage = session.newTextGameMessage(RecieveTest2, session.getServerId(), gameMessage.payload);
            session.sendGroupMessage(outMessage, -1);
            break;
        }
    }
}
  • クライアント受信処理
RealTimeClient.cs
     /**
     *  Handle data received from the Realtime server 
     */
    public virtual void OnDataReceived(object sender, DataReceivedEventArgs e)
    {
        UnityEngine.Debug.Log("OnDataReceived");
        UnityEngine.Debug.Log($"OpCode = {e.OpCode}");
        switch (e.OpCode)
        {
            // handle message based on OpCode
            default:
                break;
        }

        if (OnDataReceivedCallback != null) OnDataReceivedCallback(sender, e);
    }

別途UIを作成しビルド。
以下は別々のアプリで起動し
ルーム作成→ルーム検索→ルームへ参加→イベントを送る
と試したところ
test.gif
見栄えよくないですが
「誰かが送ったメッセージをトリガーにし、UDPでつながっているすべてのユーザーにデータを送る」
の目標は達成したので今回はここまで

再度の注意点

作成した

  • スクリプト
  • フリート
  • エイリアス

に関して使わないときは削除するようにしてください。
でないとお金がかかっちゃうので。

おわりに

走った説明になっちゃいました。
所感としては確かに簡単にリアルタイム通信ができると感じたが、
無料で気軽に試せないのがつらいところ

これから遊びの部分を作って行こうと思いましたが、金額がかかっちゃうので気が向いた時にでも

明日は @e73ryo さんのUIElementsで開発するときの問題と解決です!

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

【Unity】剣の軌跡のつくりかた

はじめに

こんにちは、中二病のZeniZeniです。
ゲームで剣を出すなら、かっこいい軌跡を出したいですよね?
色々と調べると、剣の軌跡の作り方はいろいろな方法があり、それぞれに長所・短所があります。
この記事では、それらの剣の軌跡の作り方をまとめてみようと思います。
VRでの使用を目的に調べていたので、VRに適しているかどうかの評価が多分に含まれます。

TrailRendererを使う方法

参考資料

一番オーソドックスなのは、Trail Rendererを使う方法だと思います。(記事も多いです)
- ユニティちゃんでわかるVRchatのためのunity教室 第12回 Trail Rendererで軌跡を描こう トレイルレンダラー実装編
- ケーキのPC情報集会所 【VRChat】トレイルレンダラー(Trail Renderer)を使って軌跡を描いてみよう♪♪

AnimationEventやScript使わなくても、簡単な設定でそれっぽいのができるのは素晴らしいです。
Boothにもクオリティの高い作品が多く出展されています。
- 剣向け汎用トレイル Black.Cat's様作
- 霊刀睡蓮[屈折トレイル付き] オニガワラインダストリ様作

具体的な使用方法は上記サイト様を参考にしてください。

注意点 ねじれ問題

注意点なのですが、TrailRendererにはねじれ問題が存在します。
ねじれ問題とは、下図のように、Trailの折り返し時に、軌跡がぐちゃっとしてしまう問題です。
Trailねじれ.gif
原因は
- ノード間の感覚が狭い
- トレイルの幅が広い
ことであり、これらの調節である程度は改善できますが、完全にねじれを消すことは難しいです。
こちらの動画で詳しく紹介されています。
【Unite Tokyo 2018】誘導ミサイル完全マスター

一般的な3Dゲームで使用する剣の軌跡であるならば、アニメーションが固定なのでTrailのオンオフで割と簡単にごまかせるのですが、VRだとそうはいきません。

対処法

ねじれ問題の対処方法なのですが、二種類あると思っています。
一つは、上記動画で紹介されていますが、折り返し地点だけTrailを透明にしてごまかす方法です。
Shaderを自分で実装する必要がありますが、一つのTrailだけでできるのは楽でよいです。

もう一つが、「剣向け汎用トレイル Black.Cat's様作」の実装法で、TrailRendererを大量に使用する方法です。
ねじれ問題は、トレイルの幅が広いと顕著に表れるので、幅の狭いTrailを大量に用いればぐちゃっとした軌跡ではなくなります。

スクリプトからメッシュを生成する方法

こちらの記事で紹介されている方法です。
Unityを使った3Dゲームの作り方(かめくめ) Unityでスクリプトから剣の軌跡を作成し表示する
スクリプトでメッシュを生成しているので、剣を振るアニメーションがどんなものでも調整いらずで軌跡を表示できます。

ParticleSystemを使う方法

1番汎用性が高く、個性を出しやすいのはこの方法だと思います。
ただParticleSystemを使うといっても、方法はいろいろとあります。

剣の軌跡エフェクトを作って、アニメーションに合わせて発生させる方法

これは非VRのゲームで多く採用される方法だと思います。
下のGIFのようなエフェクトを剣を振るアニメーションに合わせて発生させます。
slacheffect.gif

このようなエフェクトは下図のような連番テクスチャを作成して、ParticleSystemのTexture Sheet Animationの設定をすることでできます。
この連番テクスチャはAdobe AfterEffectを使用して作りました。
以下の動画が参考になります(英語字幕)。
Game Effect Tutorial - Slash - Part 1/4 - DucVu FX
Slash_blend_01_sheet.png
bandicam 2019-12-11 16-43-27-974.jpg

アニメーションに合わせて発生させる方法ですが、Animation Eventを使用することでできます。
Animation Eventの詳しい方はこちらを参考にしてください。
[Unity] Animation Eventを使いこなそう!

剣から細かいパーティクルを出して、軌跡っぽく見せる方法

こちらの画像みたいなことをします。
FlameEnchantEffect_Moment.jpg
動画はこちら
https://youtu.be/mU_uUo__vkw

実装方法

剣から発生させたいParticleSystemを以下のように設定します。

bandicam 2019-12-11 17-47-00-809.jpg
グローバルモジュール内のSimulationSpaceをWorldにします。
これがLocalだと、発生したパーティクルは剣との相対距離が一定のままになるので不自然になります。

bandicam 2019-12-11 17-48-42-851.jpg
Emissionモジュールの設定ですが、剣を振った時だけ軌跡を出したいというときには、Rate over TimeではなくRate over Distanceに値を入れてください。
ShapeモジュールではShapeをMesh RendererかSkinned Mesh Rendererにすることで、エフェクトの発生位置を二つ下にあるMeshに設定したMeshから発生させることができます。
使用するモデルがMesh RendererかSkinned Mesh Rendererのどちらを使っているかに注意してください。
TypeはVertexだと発生位置が偏りやすいので、EdgeかTriangleにするとよいです。

bandicam 2019-12-11 20-28-20-170.jpg
Inherit VelocityモジュールのMultiplierの数値を0より大きくすることで、剣を振った方向にパーティクルを飛ばすことができるようになります。ModeはCurrentにすると剣の速度の影響を常に受けるようになってしまうので、Initialにするのがいいと思います。

VFX Graphでやりたい場合

VFX GraphでパーティクルをSkinned Mesh RendererなどMeshから発生させたい場合、現状(2019年12月11日現在)のVFX Graphには標準でそのような機能はついていません。
Point Cacheがあるじゃんと思うかもしれませんが、あれは特定のメッシュの頂点情報を使用しており、Scene中の実際に使用するモデルの座標は参照できません。
しかしご安心ください、keijiroさんという方が公開しているUnityプロジェクトに、VFX GraphでSkinned Mesh Rendererを使用するサンプルプロジェクトがあります。
https://github.com/keijiro/Smrvfx
こちらを参考にしてください。(先ほど紹介した炎の軌跡エフェクトもVFX Graphで作成したものです)

おわりに

他にも作る方法があるかと思いますが、とりあえず自分の知る範囲のことを紹介してみました。
他に作り方が見つかり(思いつき)次第追記していこうと思います。
個人的には「剣から細かいパーティクルを出して、軌跡っぽく見せる方法」が1番使い勝手が良くて好きですね。

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

Unityを使って学園祭でVRスプラトゥーン を自作して展示した話

この記事は Unity #3 Advent Calendar 2019mast Advent Calendar 2019 の8日目の記事です!

ごめんなさい:bow_tone1:!!!3日も遅れてしまいました...(一番下の展示した話は絶賛執筆中

TL;DR

11月の頭に学園祭で下のようなVRゲームを展示した。

その技術まとめログを残して置きたくまとめた。
Gitのコミットログをたどってトピックを時系列で書いてく。

構想

まずは、 作りたいものテーマ要件 を決めるところから始めた。
2019年7月の時点で、作りたいものは「ゲーム」ということだけが決まっていた。
あとは、どんなテーマでどんなゲームを作るかを考えて行くだけであるが、そのまま夏休みを消化してしまった。

9月の終わり、「このままでは学園祭間に合わねぇ」となり、構想を急ピッチで固めることとなった。スマホやゲーム端末でどこでも簡単にゲームをできるからこそ、学園祭の「その日」「その場所」でしかできないものを目指すことにした。

作るゲームは「VRスプラトゥーン(仮)」、その名の通りVRでスプラトゥーンをするというゲームである。
アイデアが思いついてしたのは、まずは「先人はいないか」 「二番煎じではないのか」をサーベイしていくことに、そんな中見つけたのは以下のインターフェースである。

ツイートにあるインターフェースの詳細は こちら

これは新しいインターフェースであり、ゲームシステム自体は任天堂の端末で動いてるので、ゲームシステムから自分で実装していけば、二番煎じならない と踏み開発にとりかかることにした。

部屋が借りらたので、現実と全く同じようにモデリングした空間をステージにすることにした。
現実と 同じ見た目 の空間をステージにすることで、 「その日」「その場所」でしかできない という目指すべき点を満たした。同時に、メディアアートとしてのコンセプトが完成した。

目に映っているのは現実であるが、実際には現実では無く
体験している本人からは、現実の物理的な世界でできないことが、
目に映っている現実と同じように見える情報的な世界で、実現できる

あとは、実際に以下のような要件を考えていくことにした。
image.png

ゲームを作りたいといっても、制作するゲームは展示のためで、プレイしてもらう以上、自己満足は避けるべきである。
開発において、自己満足ルートに入らないように、先に要件を練って決めておくのは大切だった。

Unityで簡単お手軽VR

2019/9/29
Oculus無ェ、Viveも無ェ、お金もそれほど持って無ェ ですが、VRゲームを作っていく。Oculus RiftもGoもQuestもHTC Viveもありませんが、スマートフォンがあれば十分。

UnityのPlayer Settings > (Android)XR Settings > Virtual Reality Supportedにチェックを入れて、下のリストにVRプラットフォームを追加するだけ
image.png
今回は、Cardboard を使いました。

これで、Unityのカメラに映っている空間が自動で左目用、右目用に分かれてレンダリングされ、カメラの向きがジャイロセンサーに追従するようになった。
output00.gif
↑ これはスマホの画面です。

Google VR
https://docs.unity3d.com/ja/current/Manual/googlevr_sdk_overview.html

スマホ用のVRコントローラー

2019/10/3
基本、VRヘッドセットには専用のコントローラーがあるが、スマートフォンにはないのでスマートフォン用のVRコントローラーを探す(後々思えばDaydreamでも良かったなぁ)。Wiiリモコンだと持ちやすいし、傾きも取れるし、BluetoothでつながるがAnrdoid 8.0ではデータを取れない。そんな中ベストなソリューションとして見つけたのが、エレコムの ヴルームSDK専用コントローラSDKもあれば、Qiitaでの記事もある。これは買いだと思いAmazonでポチった。

【Vroom SDK】5230円で構築したオレオレVR開発環境が割とよかった話
https://qiita.com/drumath2237/items/479eda46afaadfb55ae4

UnityでVroom開発
https://qiita.com/TakaoWing/items/17758537d2a848f24191
image.png
エレコム VR リモコン Bluetooth [VR/AR ヴルームSDK対応] ブラック JC-VRR02VBK
※PRとかステマとかではなく 普通に良かった

Unityへの導入は上のQiitaの記事を参考にした。
実際にAndroid端末でVroomコントローラーを使うのには、Androidの位置情報のパーミッションを許可する必要があった。
image.png

部屋の計測

2019/10/15
実際の部屋がステージなので、モデリングするために計測をした。サクサクっと部屋の計測をしたかったのでレーザー距離計を導入した。1.測りたい起点にレーザー距離計を当てる 2.ボタンを押してレーザーポイントが測りたい終点を確認 3.もう一度ボタンを押すとディスプレイに距離がでる これだけ部屋の大体の大きさがわかった。

image.png
BOSCH(ボッシュ) レーザー距離計 GLM30 【正規品】

大体の形で距離を測っておいて、測れない部分は全体の長さから調整して割り出した。
image.png

部屋のモデリング

2019/10/21
Autodesk Fusion 360を使って、実際に測った寸法どおりにモデリングした。壁や床の色はテクスチャでつけるのでモデリングでは形だけを作っていく。細かいパーツはとりあえず後回しで、80%でも良いからゲームをプレイできるように、大まかなに部屋を作っていく。

image.png
※ 最初は窓も省略していた

素材の購入

2019/10/22
概形のモデリングを終え、Unity内に壁と天井と床を作成したが、壁だけだとどうにも寂しい。教室の最低限の要素として、黒板蛍光灯は必要ではありそう。だが、計測してないしモデリングの手間を考えると、教室のアセットを買うべきだと思って購入。このアセットから黒板と蛍光灯のモデルを拝借し、サンプルシーンから部屋のライティングがどうなっているかも確認した。

image.png
Japanese School Classroom

ライティングがよくわからない

2019/10/22
CGの華はやっぱりレンダリング!
よりリアルを目指すならPhysically based renderingだけど、Unityでのレンダリングは初めてで右も左もわからない。レイトレーシングを自分で書いたことがあっても、UnityにはUnityのお作法があるので、下の記事を参考に部屋のライティングを作り込んでいく。(Unityでのレンダリングについて、他にも資料/本がありましたらコメントにお願いします。

【Unity】入門ライティング設定!
https://qiita.com/Nekomasu/items/8845d076c4356809f0ff

【Unity】ライティングを理解するためにコーネルボックスを作って遊ぶ
http://tsubakit1.hateblo.jp/entry/2016/03/14/230904

【Unity】良い感じに見える(屋内向け)ライティングの設定手順
http://tsubakit1.hateblo.jp/entry/2018/01/04/235520

【Unity】"家具を動かせる" 部屋(ステージ)の見栄えを、出来る限り良い感じにする
http://tsubakit1.hateblo.jp/entry/2018/01/17/001302

Unityで、PBRなライティング環境をセットアップしてみよう
http://techblog.sega.jp/entry/2019/04/25/100000

理想は、Unity Japan Officeプロジェクト

実際に作成してできたのはこんな感じの教室
image.png
まだまだ改善の余地はあるが、まだゲームのシステムには手をつけていないので作り込みはシステムができてから...

InkPainterの導入

2019/10/22
スプラトゥーンはインクを飛ばしてステージを汚していくゲームですが、どうすればUnityで実装できるでしょうか...。Unityならアセットに頼って行くのがよさそうなのでアセットを探します。制作するゲームでは、いーす さんが作成した InkPainter を使います。

image.png
InkPainter

InkPainterはテクスチャが設定されてないと塗れない

2019/10/22
インクを付けたいMeshにはMesh ColliderInkPainter.csアタッチする。MousePainter.csMain Cameraにアタッチするとマウスでクリックしたところにインクが付く(マウスから直線上にRayを飛ばして、最初にオブジェクトと交差する交点にインクが付く
しかし、テクスチャが未設定だとインクが付かないので、Albedoにテクスチャをセットしておく。
image.png

InkPainterはモデルがUV展開されてないと塗れない

2019/10/22
ふぇ〜、壁をクリックした瞬間全部塗られてしまった...
この時点で「テクスチャがめちゃくちゃ小さくて、一瞬でテクスチャを全部塗りつぶしてしまった?」とか考えて、こねこねした。
image.png
この記事をみるとどうやらモデルがUV展開されていないと一回で塗り潰れると書いてある。
石膏像にInk Painterでペイントする【Unity】
http://bibinbaleo.hatenablog.com/entry/2017/12/10/164535

Autodesk Mayaを使ってUV展開した。この際、展開するUV座標の範囲はX:0.0-1.0 Y:0.0-1.0じゃないといけないので注意。
image.png

この時点の進捗
output01.gif

モデルがRead/Write Enabledになってないと塗れない

2019/10/25
とりあえず、このままAndroid用にプロジェクトに書き出す。そうするとUnity Editor上では動作していたが、Android上では最初はインクを塗れなかった。

Splatoonの塗りみたいのを再現したい その5
http://esprog.hatenablog.com/entry/2016/05/08/212355
この記事をみてみると

メッシュのインポート設定でRead/Write Enabledをチェックしないと正常に動作しない場合がある

とあり、次の記事をリンクしている。

RaycastHitのTexcoordが常に0ベクトルを返すのですよ
http://esprog.hatenablog.com/entry/2016/05/06/161729

Texcoordを取得してその位置にインクを塗る、といった処理をしているのですが、見ての通り正常に描画されない場合、Texcoordが常に0ベクトルを返しています。
解決するのはものすごく簡単です。メッシュのインポート設定でRead/Write Enabledにチェックを入れるだけです。

とりあえず、 メッシュのインポート設定でRead/Write Enabledにチェックを入れる ことで塗れない問題は解決した。

InkPainterでペイントのサイズを補正できるようにした

2019/10/25
一通りインクを塗れるようにして、Android用に書き出して試し塗りをしてみると、床/天井と壁でブラシのサイズが違うことに気づいた。原因はわかっている。モデルをUV展開したときの縮尺が統一されていないからである。
output02.gif
モデルの縮尺を合わせるコストの方が高そうなので、InkPainter側でモデルごとにブラシサイズに係数をかけられるようにした。
brush.Scaleは全体で設定されている値

InkCanvas.cs
/*-------- 省略 -------- */
/// <summary>
/// To set the data needed to paint shader.
/// </summary>
/// <param name="brush">Brush data.</param>
/// <param name="uv">UV coordinates for the hit location.</param>
private void SetPaintMainData(Brush brush, Vector2 uv, float paintScale) // <-- 引数を追加
{
    paintMainMaterial.SetVector(paintUVPropertyID, uv);
    paintMainMaterial.SetTexture(brushTexturePropertyID, brush.BrushTexture);
    paintMainMaterial.SetFloat(brushScalePropertyID, brush.Scale * paintScale); // <-- 係数をかける
    paintMainMaterial.SetFloat(brushRotatePropertyID, brush.RotateAngle);
    paintMainMaterial.SetVector(brushColorPropertyID, brush.Color);

/*-------- 省略 -------- */

Sphereを飛ばして塗れるように

2019/10/28
【Unity】オブジェクトを放物線を描くように目的地まで射出する
https://qiita.com/udo_nba/items/a71e11c8dd039171f86c

【Vroom SDK】5230円で構築したオレオレVR開発環境が割とよかった話
https://qiita.com/drumath2237/items/479eda46afaadfb55ae4

を参考にVirtualController.csを作成し、コントローラー代わりのGameObjectにアタッチする。

VirtualController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class VirtualController : MonoBehaviour
{

    public GameObject camera;

    public GameObject ThrowingObject;

    // Start is called before the first frame update
    void Start()
    {

    }

    // Update is called once per frame
    void Update()
    {

#if UNITY_IPHONE || UNITY_ANDROID
        transform.rotation = VvrController.Orientation();
        transform.transform.Rotate(new Vector3(0, 1, 0), -90);
#endif

        if (Input.GetMouseButtonDown(0) || VvrController.Trigger())
        {
            // マウス左クリックでボールを射出する
            ThrowingBall();
        }

        //transform.forward
    }

    private void ThrowingBall(){
        GameObject ball = Instantiate(ThrowingObject, this.transform.position, Quaternion.identity);

        Rigidbody rid = ball.GetComponent<Rigidbody>();
        //Vector3 a = new Vector3(10f, 10f, 10f);
        rid.AddForce(this.transform.right * rid.mass * 2.0f, ForceMode.Impulse);
    }
}

Vroomコントローラーのトリガーが引かれたら、シーン内にインスタンス化してあるインクボールがコピーされて、コントローラーの向いている方向へ発射するプログラムになってる。
ThrowingObjectはインクボールでRigitBody,Sphere Collider,CollisionPainter.csをアタッチしてある。

Unityの当たり判定は難しい

2019/10/28
どうしても玉が壁や床に当たらないことがあった。

Unityで速い攻撃モーションの時に当たり判定がされない問題の対応をする
https://gametukurikata.com/mesh/trailcollider

【Unity】RigidbodyのCollision Detection(衝突の検知)を変えて実験
https://ekulabo.com/rigidbody-collision-detection

衝突判定のあれこれ
https://qiita.com/moscoara_nico/items/8eee1de552601a8a8f1f

Unity-今更ながらRigidBodyのCollision Detectionについて表にまとめてみた
https://qiita.com/take_shi/items/fea7304fe9868a74d561

どうやら、速度が速い場合にはデフォルトの判定は効かないらしく Collision Detection を変更する必要がある。
image.png

CollisionPainter.csにはOnCollisionStayが実装されているので、Sphereが壁や床にあたっている間に呼ばれる。

CollisionPainter.cs
public void OnCollisionStay(Collision collision)
{
    if(waitCount < wait)
        return;
    waitCount = 0;

    foreach(var p in collision.contacts)
    {
        var canvas = p.otherCollider.GetComponent<InkCanvas>();
        if((canvas != null) && isPaintable)
            canvas.Paint(brush, p.point);
    }
}

玉が当たったら破棄

2019/10/28
インクボールが壁に当たっても玉が存在し続けるのはおかしいのでインクボールはオブジェクトにあたったら削除する。削除にはDestroy関数を使う。OnCollisionEnterなので、あたった時に関数が呼ばれるが、 CollisionPainter.csではOnCollisionStayを塗りの判定に使っていのですぐに削除するとインクがつかない。オブジェクトを破壊するまでのディレイ時間を設定しておけば塗りの判定のあとにちょうどオブジェクトが消える算段である。

Object.Destroy
https://docs.unity3d.com/ja/current/ScriptReference/Object.Destroy.html

public static void Destroy (Object obj, float t= 0.0F);

パラメータ
obj 破壊するオブジェクト
t オブジェクトを破壊するまでのディレイ時間
DestroySphere.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class DestroySphere : MonoBehaviour
{
    // Start is called before the first frame update
    void Start()
    {

    }

    void OnCollisionEnter(Collision collision)
    {
        //Debug.Log("当たった!");
        Destroy(this.gameObject,0.5);
    }

    // Update is called once per frame
    void Update()
    {

    }
}

塗り面積を計算してスコアにする

2019/10/29
本家は対戦ゲームで塗った面積比で勝敗が決まる。作成したゲームは対戦ではなく、1人でプレイするので塗った面積をスコアをする必要があった。なので、InkPainter.csに、「オリジナルのテクスチャ」と「塗ったあとのテクスチャ」を比較して、変化があったピクセルの数を計算するプログラムを追加した。

output03.gif

InkPainter.csにもともとあったSaveRenderTextureToPNG関数を参考にプログラムを作成。
PaintSet#mainTexture (Texture)がオリジナルの画像でPaintSet#paintMainTexture (RenderTexture)がインクの付いた画像になっている。TextureRenderTextureの内容は単純比較できなさそうなので、どちらもColor[]に変換して単純に色を比較した。この処理はむちゃくちゃ重いです。

Texture から Texture2D への変換
http://nakamura001.hatenablog.com/entry/20171012/1507810457

InkPainter.cs
public int CompareRenderTexture(){
    var ps = paintSet[0];
    int diff_count = 0;
    if(ps != null){
        var renderTexture = ps.paintMainTexture;
        var newTex = new Texture2D(renderTexture.width, renderTexture.height);
        RenderTexture.active = renderTexture;
        newTex.ReadPixels(new Rect(0, 0, renderTexture.width, renderTexture.height), 0, 0);
        newTex.Apply();

        Texture mainTexture = ps.mainTexture; // Material のメインテクスチャを取得
        Texture2D texture2D = new Texture2D(mainTexture.width, mainTexture.height, TextureFormat.RGBA32, false);

        RenderTexture currentRT = RenderTexture.active;

        RenderTexture _renderTexture = new RenderTexture(mainTexture.width, mainTexture.height, 32);
        // mainTexture のピクセル情報を renderTexture にコピー
        Graphics.Blit(mainTexture, _renderTexture);

        // renderTexture のピクセル情報を元に texture2D のピクセル情報を作成
        RenderTexture.active = _renderTexture;
        texture2D.ReadPixels(new Rect(0, 0, _renderTexture.width, _renderTexture.height), 0, 0);
        texture2D.Apply();

        Color[] origin_pixels = texture2D.GetPixels();

        RenderTexture.active = currentRT;

        Color[] paintedTex = newTex.GetPixels();

        for(int i = 0;i < origin_pixels.Length;i++){
            if(origin_pixels[i] != paintedTex[i])
                diff_count++;
        }

        Debug.Log(diff_count);
    }
    return diff_count;
}

Unityの重力を変える

2019/10/29
インクボールの速度が遅いと、玉が重力によって壁に到達する前に落ちてしまう。だからといって玉の速度が速いとあたり判定に失敗することがあるので、ちょうど良い放物線描くようにしたいと思った。物体が下まで落ちるのに関係する係数は重力加速度です。高校物理で習いました。

物体の速度は時間に比例する
$$ v = gt $$
物体の移動距離は時間の2乗に比例する
$$ y = \frac{1}{2}gt^2 $$

落下運動を調べる ~重力加速度~
https://www.nhk.or.jp/kokokoza/tv/butsurikiso/archive/resume006.html

【Unity】 重力を変更する
https://qiita.com/t_Kaku_7/items/fdf5bab18b65f6f9dcb4

上のQiitaの記事を参考に、重力加速度を-2にすることでちょうど良い放物線でインクボールが飛びました。

色をランダムに塗れるようにする

2019/10/29
インクの色が1つしかないと絵的に寂しいのでランダムでインクボールの色が変わるようにした。CollisionPainter#Brush#Colorが塗りの色なのでこれを変える。インクボールの色はマテリアルでセットしているのでそれに同じ色を当ててあげる。

VirtualController.cs
private void ThrowingBall(){
    GameObject ball = Instantiate(ThrowingObject, this.transform.position, Quaternion.identity);


    CollisionPainter paint = ball.GetComponent<CollisionPainter>();

    Debug.Log(paint.brush.Color);

    paint.brush.Color = def_color[(int)Random.Range(0, 6 + 1)];
    ball.GetComponent<Renderer>().material.color = paint.brush.Color;

    Rigidbody rid = ball.GetComponent<Rigidbody>();
    //Vector3 a = new Vector3(10f, 10f, 10f);
    rid.AddForce(this.transform.right * rid.mass * 4.0f, ForceMode.Impulse);
}

CubeMapを360度画像に変換する

2019/10/30
インクを塗った教室を共有できるように、VR空間を画像にする必要が出てきた。教室のある面だけを写してスクリーンショットを撮るか、テクスチャをサーバーに転送して専用のビュアーとかで見れるようにするか、とか色々アイデアを考えたが、プレイヤーがどこをよく塗ってどう共有したいかがわからない以上は、全部を画像に落とし込みたかった。

サーベイの中参考になったのは下の記事で、ツールかCubeMapを使うのが良さそうだった。

SphericalImageCam Unityエディタ内で超綺麗な360度全天周パノラマ映像を撮影する「日本作者さん」のスクリプト
http://www.asset-sale.net/entry/SphericalImageCam

Unityの基本機能だけを使ってSceneのキューブマップ(全天球画像)を作る
https://qiita.com/ELIXIR/items/c71ee67eb259bfa7d2c7

Unity でステレオ VR 動画を作成する (ほぼ完全 (?) 版)
https://qiita.com/tan-y/items/941de5c8bc3309f835d5

Unity Recorder の使い方
https://qiita.com/tan-y/items/644760a18484cbe71d43

2019-11-03-12-33-06-score-3609.jpeg
↑変換した画像

実際には下のQiitaの記事のプログラムを参考に、スコアが計算されたらカメラに映っている画像をキューブマップに落とし込んで、Shaderを使ってEquirectangularな画像を生成/保存できるようにした。

Unityでカメラのequirectangular画像を作成してみる
https://qiita.com/mechamogera/items/0b47e5947f1eee2467da

VirtualController.cs
private IEnumerator CalcResult() {
    int total_score = 0;
    for(int i = 0;i < inkcanvas.Length;i++){
        total_score += inkcanvas[i].CompareRenderTexture();
        yield return null;
    }
    // スコア計算
    textMesh2.text = $"{total_score:D}";
    yield return null;

    // ここで360度画像を生成
    // カメラの位置とか調製した方がいいかな?
    model.SetActive(false);
    Camera cam = camera.GetComponent<Camera>();
    Material convMaterial;

    int outputWidth = 4096;
    int outputHeight = 2048;
    int cubeWidth = 1280;

    Texture2D equirectangularTexture;

    Cubemap cubemap = new Cubemap(cubeWidth, TextureFormat.RGBA32, false);

    string path = Application.dataPath + @"/all" + "_painted.png";

    Shader conversionShader = Shader.Find("Conversion/CubemapToEquirectangular");
    convMaterial = new Material(conversionShader);

    RenderTexture currentRT = RenderTexture.active;

    RenderTexture renderTexture = new RenderTexture(outputWidth, outputHeight, 24);
    equirectangularTexture = new Texture2D(outputWidth, outputHeight, TextureFormat.RGB24, false);

    cam.RenderToCubemap(cubemap);

    Graphics.Blit(cubemap, renderTexture, convMaterial);
    //Graphics.Blit(cubemap, renderTexture);

    // renderTexture のピクセル情報を元に texture2D のピクセル情報を作成
    RenderTexture.active = renderTexture;
    equirectangularTexture.ReadPixels(new Rect(0, 0, outputWidth, outputHeight), 0, 0, false);
    equirectangularTexture.Apply();

    RenderTexture.active = currentRT;

    byte[] pngData = equirectangularTexture.EncodeToPNG();
    if(pngData != null)
    {
        File.WriteAllBytes(path, pngData);
    }
    Debug.Log(path);
    // https://qiita.com/ELIXIR/items/c71ee67eb259bfa7d2c7
    // https://qiita.com/mechamogera/items/0b47e5947f1eee2467da

    model.SetActive(true);

    yield return null;
}

Project Settings > Graphics > Built-in Shader SettingsAlways Included Shadersにプロジェクトに追加したConversion/CubemapToEquirectangularを追加しないとAndroid上で動作しないので注意

image.png

Androidスマートフォンに画像を保存

2019/10/30
現状だと、File.WriteAllBytesで指定しているパスは、Application.dataPathなので当然、写真アプリからはみることができない。写真アプリから見れれば最悪手動で共有をできるし、データ管理もしやすいので、写真アプリから見れるところに生成した画像を保存できるようにした。

Application.dataPath
https://docs.unity3d.com/ja/current/ScriptReference/Application-dataPath.html

【Unity3D】ファイル保存パス
https://qiita.com/bokkuri_orz/items/c37b2fd543458a189d4d

image.png

Unity Native Gallery Pluginを使うとユーザー領域にデータを保存できるようになるので導入、GitHubのページからNativeGallery.unitypackageをダウンロードしてインポートで導入完了。あとは、保存するプログラムを書き換えるだけで終わった。
【Unity】iOS の写真や Andoid のギャラリーに画像や動画を保存できる「Unity Native Gallery Plugin」紹介
http://baba-s.hatenablog.com/entry/2017/12/26/210500

VirtualController.cs
byte[] pngData = equirectangularTexture.EncodeToPNG();
if(pngData != null)
{
#if UNITY_IPHONE || UNITY_ANDROID
    NativeGallery.SaveImageToGallery(equirectangularTexture,"UnityGames",DateTime.Now.ToString("yyyy-MM-dd-hh-mm-ss")+".png");
#else
    File.WriteAllBytes(path, pngData);
#endif
}
Debug.Log(path);

共有システムにFirebaseを導入

2019/10/31
スコアの算出と共有用の画像の生成ができたら、その2つを共有できるシステムを組むことに。共有まわりのシステムにはFirebaseを使った。単に、Firebaseを使ってみたく導入を決めた。

Firebaseとは何かは適当な記事を参照してほしい。
Firebaseの始め方
https://qiita.com/kohashi/items/43ea22f61ade45972881

  • Unityからデータを受け取ったり共有ツイートをしたりするコントローラー的役割にFunctions
  • 画像データそのもの自体を保存しておくのにStorage
  • ユーザー名、スコア、画像ファイル名、ツイートURLなどを管理するのにDatabase
  • このゲームのWebサイトのために、Hosting これらの4つの機能を使って作っていくことにした。

下の記事を参考にFirebaseをinitした。

Firebase で Cloud Functions を簡単にはじめよう
https://qiita.com/tdkn/items/2ed2b01f2656fc50da8c

Cloud Functions for Firebaseが最高だった話
https://qiita.com/HALU5071/items/e43729ac5b06b0506fbe

multipart/form-dataをアップロードに対応

2019/10/31
サーバーにファイルを上げるとなるとPOSTメソッド+multipart/form-dataでアップロードすることになる。POSTメソッドを叩けるエンドポイントをFunctionsに実装した。基本は公式のドキュメントを見ながら、実装例を参考にしていく。ポストしたデータはStorageに保存されるようになっている。

Cloud Functions > ドキュメント > ガイド HTTP関数
https://cloud.google.com/functions/docs/writing/http

Cloud Functionsから、Storageにアップロードする
https://shuheitagawa.com/upload-to-storage-via-cloudfunctions/

FirebaseのHostingとCloud Functionsを利用してStorageへファイルをアップロードするデモアプリケーション
https://qiita.com/rubytomato@github/items/11c7f3fcaf60f5ce3365

index.js
const functions = require('firebase-functions');
const admin = require('firebase-admin');
const path = require('path');
const os = require('os');
const fs = require('fs');
const Busboy = require('busboy');

admin.initializeApp(functions.config().firebase);

//-------省略--------

exports.scoreUpload = functions.https.onRequest((req, res) => {
    if (req.method === 'POST') {
        const busboy = new Busboy({ headers: req.headers });
        // This object will accumulate all the uploaded files, keyed by their name.
        const uploads = {}
        const allowMimeTypes = ['image/png', 'image/jpg'];
        const tmpdir = os.tmpdir();

        const bucket = admin.storage().bucket();

        // This callback will be invoked for each file uploaded.
        busboy.on('file', (fieldname, file, filename, encoding, mimetype) => {

            if (!allowMimeTypes.includes(mimetype.toLocaleLowerCase())) {
                //console.warn('disallow mimetype: ' + mimetype);
                return;
            }

            // Note that os.tmpdir() is an in-memory file system, so should
            // only be used for files small enough to fit in memory.
            const filepath = path.join(tmpdir, filename)
            uploads[fieldname] = filepath;
            file.pipe(fs.createWriteStream(filepath));

            // ここでアップロード処理
            file.on('end', () => {
                bucket.upload(filepath,{ destination: `${filename}`,metadata: { cacheControl: 'public,max-age=31536000',contentType: mimetype } })
                .then(() => {
                    console.log('file upload success: ' + filepath);
                    return new Promise((resolve, reject) => {
                        fs.unlink(filepath, (err) => {
                            if (err) {
                                reject(err);
                            } else {
                                resolve();
                            }
                        });
                    });
                })
                .catch(err => {
                    console.error(err);
                    // TODO error handling
                });
            });
        });

        busboy.on('field', (fieldname, val, fieldnameTruncated, valTruncated, encoding, mimetype) => {
            console.log('Field [' + fieldname + ']: value: ' + val);
        });

        // This callback will be invoked after all uploaded files are saved.
        busboy.on('finish', () => {
            res.end();
        });

        // The raw bytes of the upload will be in req.rawBody. Send it to
        // busboy, and get a callback when it's finished.
        busboy.end(req.rawBody);
    } else {
        // Client error - only support POST.
        res.status(405).end();
    }
});

Unityから画像をアップロード

2019/10/31
作成したFunctionsへPOSTメソッドを使って画像とスコアをアップロードした。標準でネットワーク機能も備わっているのでUnityは本当に楽

Unityで画像をPOSTし,PHPでサーバに保存する
https://qiita.com/s_ktmr/items/6fcc770ea89fe885b8bd

UnityWebRequest
https://docs.unity3d.com/ja/current/ScriptReference/Networking.UnityWebRequest.html

VirtualController.cs
IEnumerator UploadFile(byte[] pngData,int score) {
    string fileName = DateTime.Now.ToString("yyyy-MM-dd-hh-mm-ss")+".png";

    // formにバイナリデータを追加
    WWWForm form = new WWWForm ();
    form.AddBinaryData ("file", pngData, fileName, "image/png");
    form.AddField( "score", $"{score}" );
    // HTTPリクエストを送る
    UnityWebRequest request = UnityWebRequest.Post ("https://*********.cloudfunctions.net/scoreUpload", form);
    yield return request.Send();

    if (request.isHttpError) {
        // POSTに失敗した場合,エラーログを出力
        Debug.Log (request.error);
        textMesh.text = "Ready";
    } else {
        // POSTに成功した場合,レスポンスコードを出力
        Debug.Log (request.responseCode);
        textMesh.text = "Ready";
    }
    yield return null;
}

アップロードを高速化

2019/10/31
どうしても端末でスコアを計算して、2-3MBのPNG画像をアップロードするのは時間がかかりすぎるので、高速化したかった。PNGは可逆圧縮なのでファイルサイズは大きくなる、別にTwitterに投稿する用の画像ならJPEGでも全然良いので、サーバーにアップロードするファイルはJPEGでエンコード。JPEGにするとファイルサイズが600KBぐらいになったのでStorageにも優しい。

VirtualController.cs
// ----- 省略 ------
StartCoroutine(UploadFile(equirectangularTexture.EncodeToJPG(),total_score));
// ----- 省略 ------
IEnumerator UploadFile(byte[] pngData,int score) {
    string fileName = DateTime.Now.ToString("yyyy-MM-dd-hh-mm-ss")+"-score-" + score + ".jpg";

    // formにバイナリデータを追加
    WWWForm form = new WWWForm ();
    form.AddBinaryData ("file", pngData, fileName, "image/jpg");
// ----- 省略 ------

FirebaseからTwitterを投稿

2019/10/31
Unityからアップロードした画像をFirebaseを介してTwitterへ投稿する。Node.js + Twitterで検索したら次の記事が出てきたので助かった。multipart/form-dataのアップロードに対応したときに、アップロードしたファイルを一時的なパスに保存してあるので、アップロードする画像のパスにそれを指定すればいい。

Node.jsでTwitterに画像投稿するメモ
https://qiita.com/n0bisuke/items/6b269f61152e9f336c35

index.js
const data = fs.readFileSync('/path/to/file');
const mes = `RE:REALをプレイしました! \nスコア:${mScore} \n5C511でプレイ! \n\n #playrereal #雙峰祭 #5C511`; //投稿するメッセージ

(async () => {
    //画像のアップロード
    const media = await client.post('media/upload', {media: data});
    console.log(media);

    //Twitterに投稿
    const status = {
        status: mes,
        media_ids: media.media_id_string // Pass the media id string
    }
    const response = await client.post('statuses/update', status);
    console.log(response);
    console.log(response.entities["media"][0]["media_url"]);
    res.end();
})();

上のプログラムをデプロイしたが、Firebaseで403エラーが帰ってきた...
どうやら従量課金制じゃないとTwitterAPIを叩けないらしいので従量課金制に変更した。学園祭2日で大量の処理が走るわけではないので

FirebaseでTwitterのAPIが叩きたかった
https://blog.nagatech.work/post/twitterbot/361

無料プランだと外部のHTTPリクエスト(ここではTwitterのAPIを叩くこと)は作れない
従量課金制のBlazeプランに入ると使える

Firebase FunctionでDatabaseへの書き込み

2019/11/1
今後、ユーザーによるプレイのランキングページなどを作ることを見越してゲームのプレイデータを記録できるようにした。FunctionにDatabaseの書き込みを実装した。

ホーム >Firebase Realtime Database データの保存
https://firebase.google.com/docs/database/admin/save-data?hl=ja

Firebase Cloud Functions, Realtime DatabaseでCRUD REST WebAPIを作る
https://qiita.com/devnokiyo/items/26de016b0baf60b26c90

上の記事を参考に、Tweetのレスポンスとともにデータを記録。pushでJavascriptのObjectの書き方で記録できる。

index.js
const ref = admin.database().ref('record');
ref.push({
    id: response.id_str,
    user_name: "noname",
    score: mScore,
    tweet_id: response.id_str,
    tweet_url: "https://twitter.com/rereal2019/status/" + response.id_str,
    tweet_media_uri: response.entities["media"][0]["media_url"],
    media_uri: mFilename,
    created_time: parseInt( new Date() /1000 )
}).then(data => {
    return res.end();
})
.catch(error => {
    // TODO ERRORハンドリング
    return res.end();
});

ゲーム内のスタートを改善

2019/11/1
今までは、Readyと表示された状態でコントローラーのボタンを押したらゲームをスタートできたが、これだと展示の際にコントローラーを渡したら誤って押してしまい突然ゲームが始まることがあると思った。なので、簡単なチュートリアルを兼ねて、箱にインクボールを当てるとスタートというシステムに改良した。箱には、OnCollisionStayを実装したクラスをアタッチしており当たったインクボールの数をカウントする。箱の上部には当てるべきインクボールが数が表示されており、インクボールが当たれば減る。その数が0になればOKと表示され、すべてがOKになるとゲームが始められる。(文章で書くと説明が長いのでこのシステムは良くないと今思っている...

output04.gif

メモリ使用が800MBを超えるとアプリが落ちる

2019/11/1
アプリを終了せずともゲームをスタート/ストップできるようになったのだが、どうも3,4回目をプレイして塗りを計算したあたりでアプリが落ちる。コードを見る限り、プレイ回数に依存して配列外アクセス、nullアクセスして、NullReferenceExceptionとかではなさそうだった(今思えばログ見ればよかったのでは...。とりあえずこういった問題には、Profilerを使う。Android StudioにはAndroid Profilerがあるので、

image.png

デバイスをmacにつないだままプレイしてCPUやメモリの状態を観察すると、ゲームが終了するごとに約200MBぐらい増えている。
image.png

800MBあたりを越えた時点でアプリが終了してメモリがパージされている。これはつまりメモリリークってやつですね。ゲームが1回終わるごとにアプリをリセットすれば良いが、忘れたりでもしたらプレイ結果が消えるので、これは解決するべき問題。(人間がシステムに対して行わなければいけないことは最小限にしたい
image.png

メモリリークに対してあまり詳しくないのでサーベイ。調べると、Texture系はnewして使わなくなったらDestroyしようということなのでプログラムにDestroyを追加

InkCanvas.cs
Destroy(_renderTexture);
Destroy(newTex);
Destroy(texture2D);

cubemapは1280x1280x6のテクスチャ、renderTextureは4096x2048でどちらもRGB24なので、これだけで約53MBもあるわけだ...

VirtualController.cs
Destroy(cubemap);
Destroy(renderTexture);
Destroy(convMaterial);

Unityでメモリ節約
https://qiita.com/consolesoup/items/769fdf9e5748aa3e7f05

【Unity】開発中のアプリがメモリリークで強制終了するようになった時に対応したこと
http://baba-s.hatenablog.com/entry/2017/06/29/100000

Unity RenderTextureのメモリ確保と解放タイミングの落とし穴
https://www.shibuya24.info/entry/rendertexture_mem_alloc

Unity コンポーネントがDestroyされても、オブジェクトがGCで回収されないかもしれない話
https://qiita.com/toRisouP/items/4574a30622f43ddbde79

UnityでTextureを明示的に破棄
https://qiita.com/tempura/items/b87eb07568d974664671

玉が一定範囲を越えたら消えるように

2019/11/1
窓にインクをつけるかつけないかを悩んで、つけないことにしたので窓に当たり判定をつけなかった。だから、インクボールが窓の外に出たらインスタンスが残ってしまう。重力が設定されているので、大量にインスタンスが残るとゲーム自体のパフォーマンスが悪くなってしまうので、原点からの距離を測って一定範囲外にでたら消すようにした。

unity > 物体がある範囲外に移動した時に消えるようにする > Destroy(gameObject);
https://qiita.com/7of9/items/f9843c6668eb269678de

DestroySphere.cs
    [SerializeField]
    public float DestroySphereRange = 20.0f;

    // Update is called once per frame
    void Update()
    {
        float dist = Vector3.Distance(new Vector3(0, 0, 0),transform.position);
        if (dist > DestroySphereRange) {
            Destroy(this.gameObject);
        }
    }

塗りをリセット

2019/11/1
InkPainterでインクの塗りをプログラムから消すには、InkCanvas#ResetPainitを呼べば良い。なのでInkCanvasを全部取得してforeachを回すだけ。

VirtualController.cs
public void canvasReset(){
    foreach(var canvas in FindObjectsOfType<InkCanvas>())
    canvas.ResetPaint();
}

UnityでJSONを扱う

2019/11/1
現在プレイしているユーザー名は何か、次のゲームはコンティニューなのかどうか、プレイ時間は何秒かといったゲームに関わるパラメータは動的に変更できるようにするべきなので、ゲーム開始前にパラメータをFirebaseにとりにいくようにした。パラメータはChromeからサクッと手動で追加してプログラムを追加した。

ホーム > Firebase Realtime Database データの取得
https://firebase.google.com/docs/database/admin/retrieve-data?hl=ja

↓Datebaseに記録したパラメータを取得してJSONにして返すエンドポイントのプログラム

index.js
exports.nextConfig = functions.https.onRequest((req, res) => {

    const db = admin.database().ref('next_config');
    db.once("value").then(snapshot => {
        const result = {
            is_continue : snapshot.child("is_continue").val(),
            next_user_name : snapshot.child("next_user_name").val()
        }
        res.send(JSON.stringify(result));
    })  
    .catch(error => {
        // TODO ERRORハンドリング
        return res.end();
    });
});

これで、https://*********.cloudfunctions.net/nextConfigにGETメソッドでアクセスすれば、パラメータがJSONになってダウンロードできます。

JsonUtility をつかって Unity で JSON を取り扱う方法
https://qiita.com/sea_mountain/items/6513b330983ffa003959

この記事を参考にJSONをパースしてゲームに組み込みます。

VirtualController.cs
[Serializable]
public class NextConfig
{
    public bool is_continue;
    public string next_user_name;
}

IEnumerator getConfing() {
    UnityWebRequest request = UnityWebRequest.Get("https://*********.cloudfunctions.net/nextConfig"); 
    // リクエスト送信
    yield return request.Send();

    // 通信エラーチェック
    if (request.isHttpError) {
        Debug.Log(request.error);
    } else {
        if (request.responseCode == 200) {
            // UTF8文字列として取得する
            string text = request.downloadHandler.text;
            //Debug.Log(text);
            NextConfig item = JsonUtility.FromJson<NextConfig>(text);
            //Debug.Log("" + item.is_continue);
            //Debug.Log("" + item.next_user_name);
            if(item.is_continue == false){
                canvasReset();
            }
            isConfig = true;
        }
    }
}

インクボールの色を変えれるようにする

2019/11/1
今まではインクボールの色がランダムで出ていたが、プレイヤーが好きな色を使えると良いと思いジョイスティックで色を選択できるようにした。ジョイスティックがある方向に倒れているかの状態しかAPIからは取得できないので、矢印キーを1回押す感覚で左右に倒すことはできない(Unityでいう、GetKeyはあるが、GetKeyDownがない感じ)。なのでジョイスティックを倒したというイベントを1回限りで取れるようにした。

FPGA開発でVerilogHDLを書いた気持ちで、立ち上がり検出をC#で書く

VirtualController.cs
    // 立ち上がり検知
    private bool prev_left = false;
    private bool curt_left = false;
/*-------- 省略 -------- */
    void Update(){
/*-------- 省略 -------- */
        // 左方向検知
        curt_left = VvrController.JoystickAction() == VvrJoystickAction.Left;
        if (Input.GetKeyDown(KeyCode.LeftArrow) || ((prev_left == false) && (curt_left == true))){
            colorPalette = ((colorPalette + 8) - 1) % 8;
            //Debug.Log(""+colorPalette);
            setPalette();
        }
        prev_left = curt_left;
/*-------- 省略 -------- */
}

これでジョイスティックを傾けると色が変わるようになった。
output05.gif

UnityXRで動き回るにはカメラの親オブジェクトを移動させる必要がある

2019/11/1
ジョイスティックの上入力と下入力の機能に空きがあるので、前と後ろに移動できるようにしてみる。

output06.gif

Unity標準のVR機能(UnityEngine.XR)メモ
# Virtual Reality Supportedをオンにすると何が起きるの?
https://framesynthesis.jp/tech/unity/xr/#virtual-reality-supported%E3%82%92%E3%82%AA%E3%83%B3%E3%81%AB%E3%81%99%E3%82%8B%E3%81%A8%E4%BD%95%E3%81%8C%E8%B5%B7%E3%81%8D%E3%82%8B%E3%81%AE

また、実行時にカメラのPositionとRotationがトラッキングの動きで上書きされるようになります。たとえばカメラをスクリプト等で移動しているような場合、スクリプトでのTransformの更新が上書きされて無効化されてしまいます。対策としては、カメラを別のゲームオブジェクトの子オブジェクトにして親のほうをスクリプトで動かしてください。

MainCameraのPositionを動かしても無意味なので、上の記事通り、MainCameraに親オブジェクトを作成して、そのPositionを動かすことにした。

image.png

VirtualController.cs
void Update(){
/*-------- 省略 -------- */
        // 上方向検知
        // https://framesynthesis.jp/tech/unity/xr/
        // 親オブジェクトを動かすしかない
        if (Input.GetKey(KeyCode.UpArrow) || (VvrController.JoystickAction() == VvrJoystickAction.Up)){
            //if(isPlaying)
            Vector3 pos = cameraMover.transform.position;
            pos.x += 0.1f;
            if(pos.x >= -1.8f){
                pos.x = -1.8f;
            }
            cameraMover.transform.position = pos;
        }

        // 下方向検知
        if (Input.GetKey(KeyCode.DownArrow) || (VvrController.JoystickAction() == VvrJoystickAction.Down)){
            Vector3 pos = cameraMover.transform.position;
            pos.x -= 0.1f;
            if(pos.x <= -5.6f){
                pos.x = -5.6f;
            }
            cameraMover.transform.position = pos;
        }
/*-------- 省略 -------- */
}

Unityで音をならす

2019/11/3
ゲームの見た目はだいたいできてきたので、BGMとSEを追加。SEは重複ありなので、PlayではなくPlayOneShot関数を使った。プログラムから音を鳴らしていくのに下のQiitaの記事がとても参考になった。

[Unity] Unity×音についてざっくりまとめ
https://qiita.com/lycoris102/items/5d5359b2015a8fdebaaa

【Unity】音声(BGM・SE)の再生・ループ・フェードアウトなどの設定方法を徹底解説!
https://xr-hub.com/archives/18550

その5 SEの同時発生数問題を考えてみる
http://marupeke296.com/UNI_SND_No5_NumberOfSE.html

【Unity】重複ありのBGMの鳴らし方「Play()」「PlayOneShot()」
https://squmarigames.com/2018/12/19/unity-beginner-audiosource/

ゲームのサイトを作成

2019/11/3
firebase initしたときにHostingも選択しているので、プロジェクトフォルダ下のpublicディレクトリをサクッと編集して、firebase deployコマンドを打ち込むだけ。しかし、これだとFunctionの方もデプロイが走るので下のコマンドでデプロイする。

firebase deploy --only hosting

実際にデプロイしたサイト
https://sohosai2019-rereal.firebaseapp.com/

image.png

プレイヤーネームの登録を作成

2019/11/3
デプロイしたサイトに新規ゲームのためにプレイヤーの名前を登録できるフォームのあるページを作成した。念を入れてプレイ時間とインクの塗りをリセットするか?を変えられるようなInputも作成した。プレイヤーには名前を入れるだけのフォームに見えるように、フォームのSubmitボタンを上にして、下の方にオプションの項目を並べている。

↓実際のサイト
image.png

ユーザーネームを登録できるエンドポイントはFunctionに実装しておく。もともとあったデータをDatebaseから取得して処理を進めたかったので、awaitを使えるようにfunctions.https.onRequestの引数の関数には、asyncをつけている。

async/await 入門(JavaScript)
https://qiita.com/soarflat/items/1a9613e023200bbebcb3

index.js
exports.newGame = functions.https.onRequest(async(request, response) => {
    if (request.method === 'POST') {

        console.log(request.body.iscontinue);
        console.log(request.body.username);
        console.log(request.body.options);
        const db = admin.database().ref('next_config');

        const old_user_name = (await admin.database().ref('next_config/next_user_name').once('value')).val();

        if (typeof request.body.iscontinue !== 'undefined') {
            //onのとき
            //usernameが0なら更新しない、あれば更新
            db.set(
                {
                    next_user_name: (request.body.username.length != 0)? request.body.username : old_user_name,
                    is_continue: true,
                    play_time : Number(request.body.options)
                }
            );

        }else{
            //offのとき
            //usernameが0はnoname、あれば更新
            db.set(
                {
                    next_user_name: (request.body.username.length != 0)? request.body.username : "noname",
                    is_continue: false,
                    play_time : Number(request.body.options)
                }
            );

        }
        // ルールとかにすると良い
        response.redirect('https://sohosai2019-rereal.firebaseapp.com/new.html')
    }
});

ランキング機能を追加

2019/11/3
スコアがでるゲームにはインセンティブとしてランキングが必要なので、Twitterに投稿する文でこのスコアが何位なのかわかるようにした。ランキングは、自身のスコアより高いスコアのデータの個数+1が自身の順位なので、orderByChildでスコアをソート、startAtで自身より高いスコアのデータのみにフィルタリング、numChildrenでデータの個数を取得で順位を算出した。

ホーム > Firebase Realtime Database
データの取得 指定した子キーでの並べ替え
https://firebase.google.com/docs/database/admin/retrieve-data?hl=ja#%E6%8C%87%E5%AE%9A%E3%81%97%E3%81%9F%E5%AD%90%E3%82%AD%E3%83%BC%E3%81%A7%E3%81%AE%E4%B8%A6%E3%81%B9%E6%9B%BF%E3%81%88

index.js
let rank_res = 0;
const db = await admin.database().ref('records').orderByChild("score").startAt(Number(fields["score"])).once("value").then(snapshot => {
    rank_res = snapshot.numChildren() + 1;
    //console.log("in:"+rank_res);
});

console.log(rank_res);

const data = fs.readFileSync(uploads['file']);
const mes = ((fields["user_name"] != "noname")?`${fields["user_name"]} さんが \n`:"")+`RE:REALをプレイしました! \nスコア:${Number(fields["score"])} \n現在の順位は${rank_res}位! \n\n雙峰祭5C511でプレイ! \n\n #playrereal #雙峰祭 #5C511`; //投稿するメッセージ

ランキングページを作成するために、FirebaseでCORSを許可する

2019/11/3
https://*********.cloudfunctions.net/getRankをPOSTメソッドで叩くと、JSONでランキングのデータがダウンロードできるようにした。FunctionにgetRankのエンドポイントを実装して、ページにはAjaxを使ってPOSTを叩くプログラムを実装した。まぁ簡単にはいかず、firebaseapp.comからcloudfunctions.netのドメインのAPIを叩くのだから、当然No 'Access-Control-Allow-Origin' header is present on the requested resource. とChromeに言われる。

Cloud Functions for Firebase でCORSを許可する方法
https://qiita.com/seya/items/0f12bd09c8e856123bc3

npm install cors もしくは yarn add cors でインストールして、CORSを許可したい関数を以下のようにwrapします。

上の記事を参考に、npm install corsして処理をwrapで解決した。

index.js
exports.getRank = functions.https.onRequest((req, res) => {
    cors(req, res, async() => {
/*-------- CROSを許可したい処理 -------- */
    });
});

Ajaxを使ってランキングを表示する部分(Javascriptの書き方が混ざっていてやべぇなw

rank.html
var xhr = new XMLHttpRequest();

 xhr.open('POST', 'https://*********.cloudfunctions.net/getRank');
 xhr.setRequestHeader('content-type', 'application/x-www-form-urlencoded;charset=UTF-8');
 xhr.send( 'limit=100' );
 xhr.onreadystatechange = function() {

 if(xhr.readyState === 4 && xhr.status === 200) {

     //console.log( xhr.responseText );

    let _parse = JSON.parse( xhr.responseText );

    let rank = 1;
    _parse.forEach(element => {
      console.log(element);

      let _div = document.createElement('div');
            _div.className = "mdl-card mdl-shadow--2dp";
            _div.setAttribute("style", "width:512px;");
            _div.innerHTML = "<div class=\"mdl-card__title\">"+
                              "<h2 class=\"mdl-card__title-text\">"+rank+"位</h2>"+
                            "</div>"+
                            "<div class=\"mdl-card__media\">"+
                              "<img src=\""+element.tweet_media_uri+"\" height=\"150\" alt=\"\""+
                               "class=\"test-img\">"+
                            "</div>"+
                            "<div class=\"mdl-card__supporting-text\">"+
                                "<h5>"+element.user_name+"</h5>"+
                                "<h5>スコア:"+element.score+"</h5>"+
                            "</div>"+
                            "<div class=\"mdl-card__actions\">"+
                               "<a href=\""+element.tweet_url+"\" target=\"_blank\">ツイートをみる</a>"+
                            "</div>"+
                          "</br>";
            document.getElementById("rank_column").appendChild(_div);
            document.getElementById("rank_column").appendChild(document.createElement('br'));

            rank++;

    });

 }
}

Firebase Functionの部分

index.js
exports.getRank = functions.https.onRequest((req, res) => {
    cors(req, res, async() => {
        let result = [];
        if (req.method === 'POST') {
            //console.log(req.body.limit);
            await admin.database().ref('records').orderByChild("score").limitToLast(Number(req.body.limit)).once("value").then(snapshot => {
                    snapshot.forEach(e =>{
                        result.push({
                            id: e.child("id").val(),
                            user_name: e.child("user_name").val(),
                            score: e.child("score").val(),
                            tweet_id: e.child("tweet_id").val(),
                            tweet_url: e.child("tweet_url").val(),
                            tweet_media_uri: e.child("tweet_media_uri").val(),
                            created_time: e.child("created_time").val()
                        });
                    });
                }
            );
        }
        res.send(JSON.stringify(result.reverse()));
    });
});

【JavaScript入門】FormやAjaxのPOST送信・取得の方法まとめ!
AjaxによるPOST
https://www.sejuku.net/blog/53627

FirebaseのCloud FunctionsでCORSが~とかAccess-Control-Allow-Originが~と言われたらこれ
https://qiita.com/qrusadorz/items/40234ac0b5c5c2315cad

[WIP]展示

2019/11/3-4
11月3日 11:00-16:00、11月4日 10:00-12:00 14:45-16:00の8.25時間で、総プレイ数は60回といった感じだった。普通に学園祭は人が来るので、またゲームを展示して行けると良かったりと思った。結局、当日までコーディングをしていたが、自分としては書き出したAndroidアプリが途中でクラッシュすることもFirebase上でバグを踏むこともなく動いたのにはとても満足した。必要だと思ったことは当日色々思ったがメモするのを忘れてしまったので、随時展示について細かいことをここに書いて行きます。今のところ、ゲームに関するインストラクションを作るべきなのと、水分が必要なの点が大事だとここに書いておきます。

[WIP]反省

絶賛執筆中

最後に

basic2.png

UnityFirebaseだった

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

VR向けUI/UXの検討と、その歴史

本記事は DMMグループ Advent Calendar 2019 の11日目の投稿です。
先日12/10は @mimickn さんの neo4jでグラフ同士の差分を取る でした。

はじめに

DMM.com VR研究室の クレウス(kleus_balut) です。
2018年2月からVRChatユーザーで、2019年4月からDMM.com VR研究室配属、
2019年8月末にリリースしたVRゲーム BOW MAN では、UI実装を中心に担当しました。

個人では VIVEBatteryInfo という、VRソーシャル向けのVRオーバーレイツールを開発/頒布しています。

VR向けUI/UXについて

VRのコンテンツでは「VRならでは」を求めがちですが、UI/UXもそうであるとは限りません。
むしろ、旧来のUI (本記事では以下「2D-UI」) をそのままVR空間に3Dオブジェクトとして配置した方がUXが良いケースもあります。

2D-UIなSteamVRダッシュボード

ゲーム開発ではゲーム自体の面白さに注力されるものですが、ユーザーが触れるUI/UXも地味に重要で、なおかつゲームに密接に関わってくるものなので、プロダクトの質を上げる為にバランスよく注力した方が良いと感じています。

本記事ではVR向けUI/UXについて、個人的に気を付けているところを中心に共有したいと思います。

BOW MANの当初リリース先がOculus Storeだったこともあり、下記の内容の多くが、Rift Virtual Reality Check (VRC) Guidelines - Oculus に準拠しています。

用語の定義

紛らわしい用語があるため、本記事では下記の通りに定義します。
VRChat = VRChat
VRC = OculusのVirtual Reality Check Guidelines

VR向けUI/UXの検討

HMDへの常時表示は避ける

通常のゲームではHPゲージやステータスバー等が画面に常時表示されているのでVRでも常時表示したくなりますが、これはVRでは御法度(※)とされています。
以下の観点からオススメしません。

・常に視界に何かがある不快感の誘発
・HMD毎のFOVの違いによる見辛さ

弊アプリVIVEBatteryInfoでも、バッテリー残量の合計値をHPバーのようにHMDに常時表示するアイデアがあったのですが、VRでは御法度と知ってから、初期の段階で実装を断念した覚えがあります。

※ご法度とされる明確な文面を書籍「バーチャルリアリティ学」で見かけた記憶があるのですが、当該文面を探すことができませんでした。
 VR開発者の間では、主にTwitterを発信源としてよく耳にします。

実際にHMDに照準用のポインターを常時表示してみると、まるで「飛蚊症」のようなうっとおしさがあることを実感し、なぜこれがご法度とされているのかよくわかると思います。
2D画面で見るよりも、VRで見るとうっとおしさが顕著です。
慣れの問題もあるのですが、VRChatでのマイクアイコンも当初は結構気になりました。

MenuボタンでシステムUIを開けるようにする (ゲーム中は中断できるようにする)

Oculus VRC: VRC.PC.Functional.3, VRC.PC.Input.5, VRC.PC.Input.7

VRコントローラーのMenuボタン(ゲームコントローラーで言うStartボタン)を押しても何も反応がないと不安になります。
手首をクルっと回してUIが開くと、それはそれでカッコイイのですが、表示・非表示の判定にはスマートウォッチばりの微調整が不可欠です。
ゲームに慣れた人であれば、多くの人がシステムUIを開いて音量調節や各種値のトグルができると思うでしょうが、それができないと不快に感じると思います。

システムUIは最前面表示にする

ソーシャルVRで顕著ですが、システムUIが他プレイヤーやゲーム内のオブジェクトの裏側に回ってしまうと操作ができません。
VRChatのように自由度が高いものについては視界ジャック(後述)等の脅威もあるので、UIの優先順位は最前面にする必要があると感じます。
※VRChatは今年のアップデートでシステムUIが最前面になりました。

主観視点の強制移動を行わない (主観視点でカットシーンを再生しない)

Oculus VRC: VRC.PC.Input.10, VRC.PC.Tracking.2

強制的に視界を奪い、イベント(カット)シーンを見せる作りは不快感やVR酔いを誘発する可能性があります。
VRChatでは「視界ジャック」と呼ばれるような手法もこれに近いかもしれません。

Unity製VRゲームで何も考えずにこれを実装すると、トラッキングスペースがルームスケールの場合、正面がズレます。
(ヒエラルキー上のTrackingSpaceごと回転する必要があります)
カットシーンを見るたびに、プレイヤーが自らトラッキングスペースの正面を向かなければなりません。
6DoF-VR視点で体や腕が動かないと、金縛りのような不快感を感じることもあると思います。

デメリットを理解したうえで、あえてこの演出をすることを選択していない限り、オススメできません。

小コラム:VR向けUIの歴史

※便宜上、用語については独自の言い回しがあります。
 また、説明の為に主観視点の動画がありますので、酔いやすい方はご注意ください。

VR向けUIは今日までに様々なパターンが出てきていますが、デファクトスタンダードとされているものはあまりないように思います。

直近の2018-2019年は、BeatSaberやVRChatのような「ポインター型2D-UI」が主流のようでした。
昨今のVRブームの中でVR向けUIの傾向を見ていると、以下の傾向があると感じました。

・初期(2013年):VR(コントローラー)向けのUIは皆無
・中期(2014-2016年頃):「VR空間でボタンを押す」方式
・現在(2019年):「ポインター型2D-UI」が多く、VRシステムアプリケーション(SteamVR等)でも多く使われている

ここで今一度、VRやARの歴史と共に、VR向けUIの歴史を振り返ってみたいと思います。

2013年 Oculus DK1発送開始

2013年のOculus DK1から始まる昨今のVRブームですが、当時はまだVR専用コントローラーがなく、Xbox360のコントローラー等で移動の操作をするか、何も操作するものがなかった記憶です。
UIについては、VRコントローラーがまだないころなのでVR向けのUIと呼べるものはなかった記憶です。
インタラクション方式については、この時点ではRazer Hydraでの入力がVRコントローラーに近かったです。
また、基本的にVR開発者 to VR開発者向けのコンテンツが大半を占めていました。

2016年 HTC VIVE, Oculus Touch発売

2016年は、昨今のVRブームのターニングポイントとなる年です。この年にHTC VIVEとOculus Touchが発売され、今のVRコントローラーの基本形が形作られました。
VRコントローラーを使って直感的なインタラクションを行えるので、「VR空間でボタンを押す」「VR空間のオブジェクトを掴んで何かする」ものが多かったように思います。

HTC VIVEの発売にあわせ、Valveより同時にSteamVRのチュートリアルを兼ねた「The Lab」がリリースされました。

最初の画面で「ゲームスタート」を押すのは「VR空間でボタンを押す」方式。


クリックして動画を開く
ダウンロード (1).gif

部屋内での移動はテレポート方式で、ワールド移動は「VR空間にあるオブジェクト(球)を掴んで頭に持ってくる」ことで移動ができる形でした。


クリックして動画を開く
ダウンロード (2).gif

そのほか特徴的なものでは、Oculusの「First Step」や「First Contact」がありますが、これらもほぼ同様の操作体系だった記憶があります。
2016年頃にリリースされたVRタイトルは全体的に「The Lab」をお手本にしたものが多かったように思います。

2016年後半 HoloLens1国内発送開始

このころはARの盛り上がりもあってか、ハンドジェスチャーやモーションによる入力が検討され始めた時期でもあると感じています。
ハンドジェスチャーについてはHoloLens1AirTapが印象的でした。
Body-LockedなUIの検討や、Gaze入力が実用的に使われ始めたのもこの時期な印象です。
Gaze入力に関しては、3DoF-VRで有用な入力手段として、同時期からGearVRやGoogle Cardboardのようないわゆる「スマホVR」でも多く使われていた記憶があります。

そして2019年...

そして今年までには多くのタイトルが「ポインター型2D-UI」を採用している印象があります。
VR配信や動画でよく見るものではBeatSaberが多いですね。
いつからか、SteamVRダッシュボードOculusDashもこの形になっていました。


クリックして動画を開く
ダウンロード (3).gif

直感性には欠けますが、多くのVRアプリケーション標準の操作体系となっているため、無難な使いやすさがあると思います。

いち開発者として思う、VR向けUIのありかた

これはあくまでも「私個人が個人的に思っていること」という前提を付け加えさせていただきますが、
VR向けのUIは、現代で広く使われているUIをベースにして、いったんはそのまま移植するのが良いのかなと思いました。
例えば現代であれば、スマートフォンのUIをベースにするなどです。
スマートフォンのタッチ・スワイプ・ピンチイン/アウトの操作は、街頭のデジタルサイネージディスプレイでも使われているように、広く一般的に使われています。

あまりに革新的過ぎるUIは、iOSのようなレベルの直感性/応答性、そして市場の広がりがないと受け入れられないのではないかと思います。
新しいインタラクションを取り入れる際も、スマートフォンと共通のアイコンを置くなどしたほうがUXも上がると思っています。例えばメニューを起動するボタンアイコンはハンバーガーアイコン(三)にするなど。

あまりに革新的だったり独創的すぎて不便なUIは時に、プロダクトそのものを壊します。
「それ、本当にやったほうがいいんだっけ? やるとしても、そこに何らかの仮説はあるんだっけ?」と常に問い続け、実装に落とし込んでいきたいなと思いました。

参考文献等

Rift Virtual Reality Check (VRC) Guidelines - Oculus
バーチャルリアリティ学 - 日本バーチャルリアリティ学会
ゲームデザインバイブル 第2版 - Jesse Schell

おわりに

数日前、ふとしたきっかけでVRのUI/UXについて書くことにしたのですが、思った以上に書きたいことがあったので、その部分は小コラムとしてまとめました。

明日12/12の DMMグループ Advent Calendar 2019@taumu さんです。よろしくお願いします!

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

Unityを使って手軽にスマホアプリからtoioを操作する方法(ライブラリ付き)

はじめに

Hiroshi Takagiと申します。普段は組み込みエンジニアをやっています。

toioで公開されている、技術仕様書javascriptライブラリを参考にしながら、Unityを使ってスマホアプリで動作するtoioの開発環境を作りました。

技術仕様ver2.0.0で公開されているものはほぼすべて操作可能なスクリプトもご用意したので、興味ある方はぜひ使ってみてください。

ただ、できるだけ素早く簡単にできることを目標にしたので、Bluetooth Low Energy(BLE)の有料アセットが必要になります。

Unityはつい最近始めたのですが、学習にはこの本がおすすめです。
Unityの教科書 Unity2019完全対応版

それではやっていきましょう。

使ったもの

  • Unity (2018.4.14f1)
  • toio コア キューブ 1台
  • Mac 1台 [Mac mini(2018) OS Version10.14.6]
  • iPhone 1台 [iPhone 7]
  • Xcode(11.3)

Windows,Androidではまだ試していませんので、もしやってみたかたいましたら、ご連絡いただけると嬉しいです。

アセットのインポート

まずは以下のアセットを新規2Dプロジェクトにインポートします。
https://assetstore.unity.com/packages/tools/network/bluetooth-le-for-ios-tvos-and-android-26661

アセットのImport方法が分からない方はこの記事を参考にするとよいかと思います。

キューブを操作するスクリプトの配置

以下をコピーして、プロジェクト内のC#スクリプトとして使用してください。

コード全文
CubeController.cs
using System;
using UnityEngine;


public class CubeLightParams
{
    public uint red;
    public uint green;
    public uint blue;
    public UInt16 durationMs;
    public CubeLightParams(uint red, uint green, uint blue, UInt16 durationMs)
    {
        this.red = red;
        this.green = green;
        this.blue = blue;
        this.durationMs = durationMs;
    }
}

public class CubeSoundParams
{
    public uint noteNum;
    public UInt16 durationMs;
    public CubeSoundParams(uint noteNum, UInt16 durationMs)
    {
        this.noteNum = noteNum;
        this.durationMs = durationMs;
    }
}


public class CubeController : MonoBehaviour
{
    public enum States
    {
        None,
        Scan,
        ScanRSSI,
        Connect,
        Disconnect,
        Connecting,
    }

    private const string DeviceName = "toio Core Cube";
    private const string ServiceUUID = "10B20100-5B3B-4571-9508-CF3EFCD7BBAE";

    private const string IdCharacteristic = "10B20101-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string SensorCharacteristic = "10B20106-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string ButtonCharacteristic = "10B20107-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string BatteryCharacteristic = "10B20108-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string MotorCharacteristic = "10B20102-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string LightCharacteristic = "10B20103-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string SoundCharacteristic = "10B20104-5B3B-4571-9508-CF3EFCD7BBAE";
    private const string ConfigrationCharacteristic = "10B201FF-5B3B-4571-9508-CF3EFCD7BBAE";

    private string[] _characteristics = {
        IdCharacteristic,
        SensorCharacteristic,
        ButtonCharacteristic,
        BatteryCharacteristic,
        MotorCharacteristic,
        LightCharacteristic,
        SoundCharacteristic,
        ConfigrationCharacteristic
    };

    private float _timeout = 0f;
    private States _state = States.None;
    private string _deviceAddress;
    private int _foundCharCount = 0;
    private bool _rssiOnly = false;
    private int _rssi = 0;

    void Reset()
    {
        _timeout = 0f;
        _state = States.None;
        _deviceAddress = null;
        _foundCharCount = 0;
        _rssi = 0;
    }

    void SetState(States newState, float timeout)
    {
        _state = newState;
        _timeout = timeout;
    }

    void StartProcess()
    {
        Reset();
        BluetoothLEHardwareInterface.Initialize(true, false, () =>
        {

            SetState(States.Scan, 0.1f);

        }, (error) =>
        {
            BluetoothLEHardwareInterface.Log("Error during initialize: " + error);
        });
    }

    // Use this for initialization
    void Start()
    {
        StartProcess();
    }

    // Update is called once per frame
    void Update()
    {
        if (_timeout > 0f)
        {
            _timeout -= Time.deltaTime;
            if (_timeout <= 0f)
            {
                _timeout = 0f;

                switch (_state)
                {
                    case States.None:
                        break;

                    case States.Scan:
                        BluetoothLEHardwareInterface.ScanForPeripheralsWithServices(null, (address, name) =>
                        {

                            // if your device does not advertise the rssi and manufacturer specific data
                            // then you must use this callback because the next callback only gets called
                            // if you have manufacturer specific data

                            if (!_rssiOnly)
                            {
                                if (name.Contains(DeviceName))
                                {
                                    BluetoothLEHardwareInterface.StopScan();

                                    // found a device with the name we want
                                    // this example does not deal with finding more than one
                                    _deviceAddress = address;
                                    SetState(States.Connect, 0.5f);
                                }
                            }

                        }, (address, name, rssi, bytes) =>
                        {

                            // use this one if the device responses with manufacturer specific data and the rssi

                            if (name.Contains(DeviceName))
                            {
                                if (_rssiOnly)
                                {
                                    _rssi = rssi;
                                }
                                else
                                {
                                    BluetoothLEHardwareInterface.StopScan();

                                    // found a device with the name we want
                                    // this example does not deal with finding more than one
                                    _deviceAddress = address;
                                    SetState(States.Connect, 0.5f);
                                }
                            }

                        }, _rssiOnly); // this last setting allows RFduino to send RSSI without having manufacturer data

                        if (_rssiOnly)
                            SetState(States.ScanRSSI, 0.5f);
                        break;

                    case States.ScanRSSI:
                        break;

                    case States.Connect:
                        // set these flags
                        _foundCharCount = 0;

                        // note that the first parameter is the address, not the name. I have not fixed this because
                        // of backwards compatiblity.
                        BluetoothLEHardwareInterface.ConnectToPeripheral(_deviceAddress, null, null, (address, serviceUUID, characteristicUUID) =>
                        {

                            if (IsEqual(serviceUUID, ServiceUUID))
                            {
                                for (int i = 0; i < this._characteristics.Length; i++)
                                {
                                    if (IsEqual(characteristicUUID, this._characteristics[i]))
                                    {
                                        this._foundCharCount++;
                                    }
                                }
                                // if we have found all characteristics that we are waiting for
                                // set the state. make sure there is enough timeout that if the
                                // device is still enumerating other characteristics it finishes
                                // before we try to subscribe
                                if (this._foundCharCount == this._characteristics.Length)
                                {
                                    SetState(States.Connecting, 0);
                                    batterySubscribe();
                                    motionSensorSubscribe();
                                    buttonSubscribe();
                                    idInformationSubscribe();
                                }
                            }
                        });
                        break;
                }
            }
        }
    }

    bool IsEqual(string uuid1, string uuid2)
    {
        return (uuid1.ToUpper().CompareTo(uuid2.ToUpper()) == 0);
    }

    //
    // Motor
    //

    public void Move(int left, int right, uint durationMs)
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        byte leftDir = (byte)((left >= 0) ? 01 : 02);
        byte rightDir = (byte)((right >= 0) ? 01 : 02);
        byte leftVal = (byte)Math.Min(Math.Abs(left), 0xff);
        byte rightVal = (byte)Math.Min(Math.Abs(right), 0xff);
        byte dur = (byte)Math.Min(durationMs / 10, 0xff);

        byte[] data = new byte[] { 02, 01, leftDir, leftVal, 02, rightDir, rightVal, dur };

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, MotorCharacteristic, data, data.Length, false, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });
    }

    public void MoveStop()
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        byte[] data = new byte[] { 01, 01, 01, 00, 02, 01, 00 };

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, MotorCharacteristic, data, data.Length, false, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });
    }

    //
    // Light
    //

    public void LightUp(CubeLightParams[] arr, uint repeat)
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        if (arr.Length >= 30)
        {
            Debug.Log("too much array Length");
            return;
        }
        byte[] data = new byte[3 + 6 * arr.Length];
        int len = 0;

        data[len++] = 04;
        data[len++] = (byte)repeat;
        data[len++] = (byte)arr.Length;
        for (int i = 0; i < arr.Length; i++)
        {
            data[len++] = (byte)Math.Min(arr[i].durationMs / 10, 0xff);
            data[len++] = 01;
            data[len++] = 01;
            data[len++] = (byte)arr[i].red;
            data[len++] = (byte)arr[i].green;
            data[len++] = (byte)arr[i].blue;
        }

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, LightCharacteristic, data, data.Length, true, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });
    }

    public void LightOff()
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        byte[] data = new byte[] { 01 };

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, LightCharacteristic, data, data.Length, true, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });
    }

    //
    // Sound
    //

    public void Sound(CubeSoundParams[] arr, uint repeat)
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        if (arr.Length >= 60)
        {
            Debug.Log("too much array Length");
            return;
        }
        byte[] data = new byte[3 + 3 * arr.Length];
        int len = 0;

        data[len++] = 03;
        data[len++] = (byte)repeat;
        data[len++] = (byte)arr.Length;
        for (int i = 0; i < arr.Length; i++)
        {
            data[len++] = (byte)Math.Min(arr[i].durationMs / 10, 0xff);
            data[len++] = (byte)arr[i].noteNum;
            data[len++] = 0xff;
        }

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, SoundCharacteristic, data, data.Length, true, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });

    }

    public void SoundPreset(uint id)
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        byte[] data = new byte[] { 02, (byte)id, 0xff };

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, SoundCharacteristic, data, data.Length, true, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });

    }

    public void SoundOff()
    {
        if (_state != States.Connecting)
        {
            Debug.Log("Cube is not ready");
            return;
        }
        byte[] data = new byte[] { 01 };

        BluetoothLEHardwareInterface.WriteCharacteristic(_deviceAddress, ServiceUUID, SoundCharacteristic, data, data.Length, true, (characteristicUUID) =>
        {
            BluetoothLEHardwareInterface.Log("Write Succeeded");
        });

    }

    //
    // Battery
    //

    private Action<uint> batteryCb = null;

    private void batterySubscribe()
    {
        BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, BatteryCharacteristic, null, (address, characteristic, bytes) =>
        {
            if (this.batteryCb != null)
            {
                this.batteryCb(bytes[0]);
            }
        });
    }
    public void GetBattery(Action<uint> result)
    {
        this.batteryCb = result;
    }

    //
    // Motion Sensor
    //

    private bool lastCollisiton = false;
    private Action<bool, bool> motionSensorCb = null;

    private void motionSensorSubscribe()
    {
        BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, SensorCharacteristic, null, (address, characteristic, bytes) =>
        {
            Debug.Log("motion sensro changed");
            if (this.motionSensorCb != null)
            {
                if (bytes[0] == 01)
                {
                    bool flat = (bytes[1] == 01);
                    bool collisiton = (bytes[2] == 01);
                    this.motionSensorCb(flat, collisiton);
                    this.lastCollisiton = collisiton;
                }
            }
        });
    }

    public void GetMotionSensor(Action<bool, bool> result)
    {
        this.motionSensorCb = result;
    }

    //
    // Button
    //

    private Action<bool> buttonCb = null;

    private void buttonSubscribe()
    {
        BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, ButtonCharacteristic, null, (address, characteristic, bytes) =>
        {
            if (this.buttonCb != null)
            {
                if (bytes[0] == 01)
                {
                    this.buttonCb(bytes[1] == 0x80);
                }
            }
        });
    }
    public void GetButton(Action<bool> result)
    {
        this.buttonCb = result;
    }

    //
    // ID Information
    //

    private Action<UInt16, UInt16, UInt32, UInt16> idInformationCb = null;

    private void idInformationSubscribe()
    {
        UInt16 positionX = 0xffff;
        UInt16 positionY = 0xffff;
        UInt16 angle = 0xffff;
        UInt32 standardID = 0xffffffff;

        BluetoothLEHardwareInterface.SubscribeCharacteristicWithDeviceAddress(_deviceAddress, ServiceUUID, IdCharacteristic, null, (address, characteristic, bytes) =>
        {
            if (this.idInformationCb != null)
            {
                switch (bytes[0])
                {
                    case 01:
                        positionX = (UInt16)(bytes[1] | (bytes[2] << 8));
                        positionY = (UInt16)(bytes[3] | (bytes[4] << 8));
                        standardID = 0xffffffff;
                        angle = (UInt16)(bytes[5] | (bytes[6] << 8));
                        this.idInformationCb(positionX, positionY, standardID, angle);
                        break;
                    case 02:
                        positionX = 0xffff;
                        positionY = 0xffff;
                        standardID = (UInt32)(bytes[1] | (bytes[2] << 8) | (bytes[3] << 16) | (bytes[4] << 24));
                        angle = (UInt16)(bytes[5] | (bytes[6] << 8));
                        this.idInformationCb(positionX, positionY, standardID, angle);
                        break;
                    case 03:
                    case 04:
                        positionX = 0xffff;
                        positionY = 0xffff;
                        standardID = 0xffffffff;
                        angle = 0xffff;
                        this.idInformationCb(positionX, positionY, standardID, angle);
                        break;
                }
            }
        });
    }

    public void GetIdInformation(Action<UInt16, UInt16, UInt32, UInt16> result)
    {
        this.idInformationCb = result;
    }


}

このスクリプトは、自動的にキューブを探してペアリングして、完了すると各機能の操作および状態の通知が実行されるようになっています。

サンプルアプリケーションの作成

以下をコピーして、プロジェクト内のC#スクリプトとして使用してください。

コード全文
Director.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;

public class Director : MonoBehaviour
{
    GameObject Battery;
    GameObject Flat;
    GameObject Collision;
    GameObject CubeController;
    GameObject Button;
    GameObject PositionID;
    GameObject StandardID;
    GameObject Angle;

    GameObject Tap;

    bool enMove = false;

    // Start is called before the first frame update
    void Start()
    {
        this.Battery = GameObject.Find("Battery");
        this.Flat = GameObject.Find("Flat");
        this.Collision = GameObject.Find("Collision");
        this.CubeController = GameObject.Find("CubeController");
        this.Button = GameObject.Find("Button");
        this.PositionID = GameObject.Find("PositionID");
        this.StandardID = GameObject.Find("StandardID");
        this.Angle = GameObject.Find("Angle");
        this.Tap = GameObject.Find("Tap");

        notifyStatus();
    }

    // Update is called once per frame
    void Update()
    {
        if (Input.GetMouseButtonDown(0))
        {
            if (enMove)
            {
                stopMove();
            }
            else
            {
                startMove();
            }
            enMove = !enMove;
        }
    }

    private void startMove()
    {
        CubeSoundParams[] sound = new CubeSoundParams[4];
        sound[0] = new CubeSoundParams(60, 200);
        sound[1] = new CubeSoundParams(72, 200);
        sound[2] = new CubeSoundParams(84, 200);
        sound[3] = new CubeSoundParams(128, 1000);

        CubeLightParams[] light = new CubeLightParams[4];
        light[0] = new CubeLightParams(255, 255, 0, 400);
        light[1] = new CubeLightParams(0, 255, 255, 400);
        light[2] = new CubeLightParams(255, 0, 255, 400);
        light[3] = new CubeLightParams(255, 255, 255, 400);

        this.CubeController.GetComponent<CubeController>().Sound(sound, 0);
        this.CubeController.GetComponent<CubeController>().LightUp(light, 0);
        this.CubeController.GetComponent<CubeController>().Move(20, 20, 0);
        this.Tap.GetComponent<Text>().text = "Tap to stop moving";
    }
    private void stopMove()
    {
        this.CubeController.GetComponent<CubeController>().SoundOff();
        this.CubeController.GetComponent<CubeController>().LightOff();
        this.CubeController.GetComponent<CubeController>().MoveStop();
        this.Tap.GetComponent<Text>().text = "Tap to start moving";
    }

    private void notifyStatus()
    {
        this.CubeController.GetComponent<CubeController>().GetBattery((result) =>
        {
            this.Battery.GetComponent<Text>().text = "Battery残量 : " + result.ToString("D3") + "%";
        });
        this.CubeController.GetComponent<CubeController>().GetMotionSensor((isFlat, isCollision) =>
        {
            if (isFlat)
            {
                this.Flat.GetComponent<Text>().text = "水平検出 : 水平";
            }
            else
            {
                this.Flat.GetComponent<Text>().text = "水平検出 : 水平でない";
            }
            if (isCollision)
            {
                this.Collision.GetComponent<Text>().text = "衝突検出 : 衝突あり";
            }
            else
            {
                this.Collision.GetComponent<Text>().text = "衝突検出 : 衝突なし";
            }
        });
        this.CubeController.GetComponent<CubeController>().GetButton((result) =>
        {
            if (result)
            {
                this.Button.GetComponent<Text>().text = "ボタン : ON";
            }
            else
            {
                this.Button.GetComponent<Text>().text = "ボタン : OFF";
            }
        });
        this.CubeController.GetComponent<CubeController>().GetIdInformation((positionX, positionY, standardID, angle) =>
        {
            if (positionX != 0xffff || positionY != 0xffff)
            {
                this.PositionID.GetComponent<Text>().text = "X座標 : " + positionX.ToString("D3") + " / Y座標 : " + positionY.ToString("D3");
            }
            else
            {
                this.PositionID.GetComponent<Text>().text = "Position ID情報なし";
            }
            if (standardID != 0xffffffff)
            {
                this.StandardID.GetComponent<Text>().text = "Standard Id : " + standardID.ToString("D7");
            }
            else
            {
                this.StandardID.GetComponent<Text>().text = "Standard ID情報なし";
            }
            if (angle != 0xffff)
            {
                this.Angle.GetComponent<Text>().text = "角度 : " + angle.ToString("D3");
            }
            else
            {
                this.Angle.GetComponent<Text>().text = "角度情報なし";
            }
        });

    }
}

今回のサンプルアプリケーションの構成です。

下図のように、空のObjectを二つ(CubeController, Director)とTextオブジェクト(Battery, Flat, Collision, Button, PositionID, StandardID, Angle, Tap)を8個を登録してください。
名前を間違えるとうまく動きませんのでご注意ください。Textの配置とサイズは適当にお願いします。

最後に、CubeControllerにCubeController.csを、DirectorにDirector.csをアタッチすれば準備完了です。

試しにPC上で、実行して動作するか確認してみてください。
スクリーンショット 2019-12-13 午後4.28.18.png

ビルドしてスマホに移す

この記事を参考にするとよいと思います。

iOSの話になってしまいますが、一点注意点です。
XCODEから実行する際に、下図のように、Infoに[Privacy - Bluetooth Always Usage Description]を追加する必要があります。これがないとアプリが起動できなくなります。
スクリーンショット 2019-12-11 0.45.03.png

さいごに

うまく動きましたでしょうか?
あとはサンプルアプリケーションを参考に、自分のオリジナルのtoioアプリが作成できると思いますので、ぜひ使ってみてください。

最新のtoioの技術仕様書を見ると、ver2.1.0に更新されているので、またアップデートしてご紹介したいと思います。

それではよいクリスマスをお過ごしください。

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

Unityでデスクトップマスコットを作ってみた

はじめに

はじめまして。木更津高専 Advent Calendar 2019 11日目担当の、わくと です。
初めてのQiita記事です。がんばります!

概要

コーディング中・ブラウジング中に画面の右下あたりが寂しくなることはないですか?
そんなときにぴったりな、いつでも見守ってくれるデスクトップマスコットを作りました!

開発環境

  • Windows10
  • Unity2019.2.14f1

使用したツール・モデル

デモ


まばたきとマウス追従をしています。

実装

ボーン・モーフなどはアニメーションなどを使わず、すべてスクリプトで動かしています。
また、ウィンドウの透過などの処理はWindowsAPIを叩いています。

モデル取り込み

まず、MMDのモデルをMMD4Mecanimを使ってインポートします。この記事がわかりやすかったです。
取り込む前にreadmeをしっかり確認します。

表情

表情のスクリプトです。長いので簡略化してあります。

MorphController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class MorphController : MonoBehaviour
{
    MMD4MecanimModel model;
    MMD4MecanimModel.Morph[] morph;

    Dictionary<string, int> morphIndex = new Dictionary<string, int>(); // モーフのインデックス番号
    HashSet<int> changedMorphIndex = new HashSet<int>();

    void Start() {
        model = GetComponent<MMD4MecanimModel>();
        morph = model.morphList;
        int i = 0;
        foreach (var tmp in morph) {
            morphIndex.Add(tmp.morphData.nameJp, i++);
        }
    }

    void Update() {
        int screenX = Screen.width;
        int screenY = Screen.height;
        Vector3 mouse = Input.mousePosition;
        mouse.x = mouse.x / screenX / 2 - 1;
        mouse.y = mouse.y / screenY / 2 - 1;
        setMorph("瞳_上", mouse.y);
        setMorph("瞳_下", -mouse.y);
        setMorph("瞳_左", mouse.x);
        setMorph("瞳_右", -mouse.x);
    }

    // Morphの名前を渡すとvalueをセットしてくれる関数
    void setMorph(string name, float value) {
        model.morphList[morphIndex[name]].weight = value;
        changedMorphIndex.Add(morphIndex[name]);
    }
}

マウスの座標を、中心が(0,0)、縦方向・横方向それぞれを-1.0~1.0の範囲になるように正規化して、そのまま目のモーフに代入しています。
実際は眉毛など他の場所も操作していますが、省略します。
このスクリプトをモデルにアタッチします。

体関節

表情とほぼ一緒ですが、ボーンを動かすため回転情報を渡している点が違います。

MorphController.cs
// 表情のスクリプトのつづき

Dictionary<string, int> boneIndex = new Dictionary<string, int>(); // ボーンのインデックス番号
HashSet<int> changedBoneIndex = new HashSet<int>();
float mag = 6.0f; // 回転情報を渡すときの倍率

void Start() {
    // 略
    foreach (var tmp in model.boneList) {
        boneIndex.Add(tmp.boneData.nameJp, tmp.boneID);
    }
}

void Update() {
    // 略
    setBone("頭", Mathf.Abs(mag * -mouse.y) <= 20f ? mag * -mouse.y : 20f * -Mathf.Sign(mouse.y),
                  Mathf.Abs(mag * -mouse.x) <= 10f ? mag * -mouse.x : 10f * -Mathf.Sign(mouse.x),
                  Mathf.Abs(mag / 2 * mouse.x) <= 5f ? mag / 2 * mouse.x : 5f * Mathf.Sign(mouse.x));

    setBone("腰", Mathf.Abs(mag / 2 * -mouse.y) <= 3f ? mag / 2 * -mouse.y : 3f * -Mathf.Sign(mouse.y),
                  Mathf.Abs(mag / 2 * -mouse.x) <= 10f ? mag / 2 * -mouse.x : 10f * -Mathf.Sign(mouse.x),
                  Mathf.Abs(mag / 2 * mouse.x) <= 5f ? mag / 2 * mouse.x : 5f * Mathf.Sign(mouse.x));
}

// ボーンの名前を渡すと値をセットしてくれる関数
void setBone(string name, float x, float y, float z) {
    Vector3 value = new Vector3(x, y, z); 
    model.boneList[boneIndex[name]].userEulerAngles = value;
    changedBoneIndex.Add(boneIndex[name]);
}

回転しすぎないようにするために三項演算子を使っているため少し複雑に見えますが、やっていることはマウスの座標に倍率をかけて代入しているだけです。

なでなで

個人的に考えるのが一番大変だったところです。
なでなで処理は頭の上でのマウスの移動距離を使っています。

NadeNadeController.cs
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class NadeNadeController : MonoBehaviour
{
    public static bool isNadeNade = false;  // なごみ状態にあるか

    public static float mouseMov = 0;   // なでなで中のマウス移動距離
    bool hasNadeing = false;            // なでなでしているか
    int noNadeframe = 0;                // なでなでしてないフレーム数

    public int nadeRate = 10;   // なでなでを検知するしきい値
    public int noNadeRate = 5;  // なでなでしていない状態を検知するしきい値

    Vector2 mousePos;       // 現在のフレームのマウス座標
    Vector2 prevMousePos;   // 一つ前のフレームのマウス座標

    void Update()
    {
        if(noNadeframe * Time.deltaTime > 0.5f) {
            isNadeNade = false;
            mouseMov = 0;
        }

        if(!hasNadeing) {
            noNadeframe++;
        }

        if(mouseMov >= 1000) 
            isNadeNade = true;
        else
            isNadeNade = false;
    }

    private void OnMouseEnter() {
        hasNadeing = true;
        noNadeframe = 0;
        prevMousePos = Input.mousePosition;
    }

    private void OnMouseOver() {
        mousePos = Input.mousePosition;
        float distance = Mathf.Abs(Vector2.Distance(mousePos, prevMousePos)); // マウスの移動した距離
        mouseMov += distance < Screen.width / nadeRate ? 0 : distance;
        if(distance < Screen.width / noNadeRate) {
            noNadeframe++;
        } else {
            noNadeframe = 0;
        }
        prevMousePos = mousePos;
    }

    private void OnMouseExit() {
        hasNadeing = false;
        noNadeframe = 0;
    }
}

特に難しいことはしていませんが、一定時間以上マウスが動かないでいると普通の表情に戻ります。
当たり判定は下の画像のようになっています。
当たり判定.png

おわりに・感想

頑張ればがんばった分だけ可愛くなるので作っていて楽しかったです。
簡単に作れるので、自分の推しで作ってみるのはいかがでしょうか?
まだまだ、待機モーションなど付け足したい機能があるので、これからも開発を続けていこうと思います!
なにかあったらTwitterに連絡してもらえると助かります。

(自分の書いたコードを公開するって結構恥ずかしいんですね…)

参考サイト

https://qiita.com/hiroyuki_hon/items/931c79164b0ffe19517f
https://qiita.com/mkt_/items/82f4057f51b1657c971e
https://qiita.com/gatosyocora/items/7cbe14914f8e603f2eab
http://chokuto.ifdef.jp/urawaza/api/

著作権表記

©2019 cover corp.

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

【Unity】SerializeFieldの警告を削除する。

問題

以下のコードでは、値が初期化されていない旨の警告が発生する。

    [SerializeField] private CardController cardPrefub;

warning CS0649: フィールド 'GameManager.cardPrefub' は割り当てられません。常に既定値 null を使用します。

解決

    [SerializeField] private CardController cardPrefub = default;

・default値を設定する。
・default値とは初期値を設定しなかった場合に割り振られる値。
 int なら 0, float なら0.0, objectなら null

大量の警告を一括解消する。(初期値が設定されていないものを対象。)
置換前 : (?=^[^=]+$)[SerializeField](.+)
置換後 : [SerializeField]\1 = default;

参考サイト

[SerializeField] 属性をつけていると、CS0649警告が出るようになった。消したい。

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

UnityでglTFローダを作ってみたはなし

はじめに

この記事は、ドワンゴ Advent Calendar 2019の11日目の記事です。

ドワンゴではniconicoの課金システムの開発をしています。
課金関係のことに触れられればよかったのですが、普段は趣味でUnity+C#を触っているため、
今回はそちらに関する記事です。
シンプルなglTFローダの作り方などを解説している記事はありますが、複雑なモデル描画まで行っている記事が見当たらず、
かなり苦戦したため書き残しておきます。ようは備忘録です。
Qiita記事はドワンゴに来る前に非公開記事として共有することにしか使ってなかったため、他記事に比べ読みづらいと思いますが生暖かい目で見守ってください。

何故やろうとしたか

VR向けアバターフォーマットであるVRMはglTFをベースとしているため、glTF自体に興味はあったが
触る機会がなく、ふわっとしか理解していなかったので、リファレンスを参考に自分で作ってみようと思いました。
(VRMのロード時間最適化したいといったのもありますが)。

実際にやったこと

GLBの処理

GLBファイルのバイト配列をSpanにし、ヘッダ部分を Slice して MemoryMarshal でCastすることで構造体として扱いました。
Unityでも、 System.Memory をnugetから落としてくれば利用できるためぜひ使ってみてください。

var headerSpan = glbSpan.Slice(0, Marshal.SizeOf<Format.GLBHeader>());
var header = MemoryMarshal.Cast<byte, Format.GLBHeader>(headerSpan)[0];

チャンクを処理する際も、 MemoryMarshal で処理することでほぼ処理速度を気にせず構造体配列として扱えます。
この時、フォーマットの種類である JSON or BIN の判定を、intとして処理することで1byteずつASCIIで判定することなく行えます(つまり、enumで判定できます)ので、フォーマット部はintで行うことをおすすめします。

JSON: 0x4e4f534a
BIN:  0x004e4942

glTFのJSON部処理

mebiusbox/gltf(Github) で公開されている以下の画像を参考にパーサを実装しました。

Quickreference
最初はglTFのjsonスキーマを読みながら実装をしていたのですが、このクイックリファレンスのおかげでかなり楽に行えました。

メッシュ処理

メッシュには Primitive が配列として格納されているため、それをループで処理します。
Primitive = Unityで言うサブメッシュを表します。

先にvertex数分確保が必要ですが、Primitive1個目のPosition数*Primitive数で初期化しました。
ここでは、 PositionNormalTexCoord0 をこの基準で格納しています。
Indices はPrimitiveの数分のジャグ配列を初期化し、それぞれ格納しています。

実際にUnity上のメッシュにする際は、 subMeshCountPrimitive の数をセットし、 SetTriangles メソッドで Indices をセットしました。

var umesh = new Mesh
{
    vertices = position,
    normals = normal,
    uv = texcoord0.ToArray(),
    subMeshCount = mesh.Primitives.Count
};

for (var i = 0; i < mesh.Primitives.Count; i++)
    umesh.SetTriangles(indices[i], i);

そのまま描画しようとすると座標系の違いにより左右反転して表示されるため、変換を行う必要があります。
ここで変換が必要なのは PositionNormal です(TangentはVRMの仕様上、含まれないため省いています)
単純にPositionのx座標を反転し、IndicesをVector3として見立てた状態での、X座標とZ座標を入れ替えることで行いました。

困ったこと

右手系座標を左手系座標へ変換する際のSpanのパフォーマンス

Accessorの値配列を得るために、Spanを用いることで高速化しようとしましたが、書き換え時のパフォーマンスがよくありませんでした
(1000ループで約3000ns)
MemoryMarshal.Castfloat にキャストした後、該当位置に書き込みなども行いましたが、まったくパフォーマンスがよくならず、頭を抱えました。
結果としては、現在のUnityでの .NET Standard バージョンが 2.0 であり、ランタイムに最適化が入っていないことによるパフォーマンス低下であり、一度通常の配列として持ち、それをポインタ経由でいじることでパフォーマンスがよくなりました。
そのため、事前に一定のヒープを確保しておき、そこに保持することで高速化することが出来ました。

まとめ

実際に自分の手でローダを作ってみることで、glTF自体や、Unityの描画周りなどを知ることができました。
車輪の再発明ではありますが、パフォーマンスチューニングを含む様々な知見を得られたため、かなりプラスでした。

まだ全体の実装が出来てないため、出来上がったらコード込みの詳細の記事を出そうと思います。

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

【Unity】Grid Layout Groupに追加されたGameObjectをソートする

Grid Layout Groupに追加されたGameObjectをソート

概要

ゲーム中のアイテムリスト、カードリスト等、ScrollViewなどでグリッドレイアウトは頻繁に使用されますが、これはUnity標準のコンポーネントであるGrid Layout Groupで解決することができます。
しかし、そういったリストの大半はソート機能が必要になる場合がほとんどです。
調べた限り、UnityにはのGrid Layout Group上に配置された子GameObjectをソートするような機能は、標準で存在しないようです。
本記事はGrid Layout Groupのソートについて、メモレベルで説明します。

Grid Layout Groupの使い方そのものについては、本記事の下部に記載している参考記事などをご参照下さい。

想定

Grid Layout Group(以降Contentと呼ぶ)に対して、以下ように、Prefabを子GameObject(以下セルと呼ぶ)として動的に生成、関連付けている。

var childObj = Instantiate(/* セルのPrefab */); // セルを生成
childObj.transform.parent = contentObj.transform; // セルをContent配下に配置

この状態から昇順/降順にソートしたい、というシチュエーションを想定しています。

やりかた

以下に雰囲気コードを示します。

var childObjs = /* 何らかの方法でContent内のセル(GameObject)の一覧を取得 */;
var siblingIndexes = childObjs.Select(c => c.transform.GetSiblingIndex()).Reverse().ToArray();
for (var i = 0; i < childObjs.Count; i++) {
    childObjs[i].transform.SetSiblingIndex(siblingIndexes[i]);
}

コード解説

  1. セルをSelectし、全SiblingIndexを取得(GetSiblingIndex)します。
  2. 前項で取得したSiblingIndexをReverseし、逆順のSiblingIndexを取得します。
  3. セルを元の順番で順次参照し、前項のSiblingIndexをセット(SetSiblingIndex)します。

補足

ある階層に並列で配置されたオブジェクト、すなわち兄弟(Sibling)間のオブジェクトの表示順序は、Unityにより管理されるSiblingIndexによって決定されています。
このSiblingIndexは、例えばContent以下の兄弟オブジェクトの場合、Unityのエディタ上の見た目そのまま、Hierarchyの表示順に、0から順に振られています。
 
例:Hierarchyがこんな感じとした場合のSiblingIndex

...
 └ Content // ScrollViewのContentとか
  ├ childObjA // 0
  ├ childObjB // 1
  ├ childObjC // 2
  └ childObjD // 3
...

Grid Layout Groupの1セルの表示順(Hierarchyの配置順)はSiblingIndexにより決定しています。
ですので、セルのソートを反転したい場合は、現在のSiblingIndexを逆順で取得(GetSiblingIndex)し、
それを現在のセルの配置順に上書き(SetSiblingIndex)していくことでセルの表示順を反転させることができます。

例:すべてのセルのSiblingIndexをReverseしてセットし直した状態のHierarchy

...
 └ Content // ScrollViewのContentとか
  ├ childObjD // 0
  ├ childObjC // 1
  ├ childObjB // 2
  └ childObjA // 3
...

応用メモ

単純なソートは昇順/降順ソートは上記のように行うことができますが、
たとえば、セルであるGameObjectにスクリプトをアタッチし、そこに「種別」などの情報をもたせて、より複雑なソートアルゴリズムを適用できるでしょう。
今回例に示したソースはReverseを使用して単純反転させましたが、特にこれを使用しなければならないという事はありません。

参考資料

Grid Layout Groupの使い方

https://qiita.com/t_Kaku_7/items/588fada25cf2d519589d
https://tech.pjin.jp/blog/2016/08/30/unity_skill_3/

SiblingIndexについて

https://forum.unity.com/threads/order-of-elements-in-layout-groups.267668/
https://qiita.com/T_2/items/69c22e649441055d2329

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

【Unity】Grid Layout Groupに追加されたGameObjectをソート

Grid Layout Groupに追加されたGameObjectをソート

概要

Unity標準のコンポーネントであるGrid Layout Groupのソートについて、メモレベルで説明します。
(日本語のサイトがなかなか見当たらなかったため)

Grid Layout Groupの使い方そのものについては、本記事の下部に記載している参考記事などをご参照下さい。

想定

Grid Layout Group(以降Contentと呼ぶ)に対して、以下ように、Prefabを子GameObject(以下セルと呼ぶ)として動的に生成、関連付けている。

var childObj = Instantiate(/* セルのPrefab */); // セルを生成
childObj.transform.parent = contentObj.transform; // セルをContent配下に配置

この状態から昇順/降順にソートしたい、というシチュエーションを想定しています。

やりかた

以下に雰囲気コードを示します。

var childObjs = /* 何らかの方法でContent内のセル(GameObject)の一覧を取得 */;
var siblingIndexes = childObjs.Select(c => c.transform.GetSiblingIndex()).Reverse().ToArray();
for (var i = 0; i < childObjs.Count; i++) {
    childObjs[i].transform.SetSiblingIndex(siblingIndexes[i]);
}

コード解説

  1. セルをSelectし、全SiblingIndexを取得(GetSiblingIndex)します。
  2. 前項で取得したSiblingIndexをReverseし、逆順のSiblingIndexを取得します。
  3. セルを元の順番で順次参照し、前項のSiblingIndexをセット(SetSiblingIndex)します。

補足

あるオブジェクトの配下にあるオブジェクト、すなわち兄弟(Sibling)間のオブジェクトの表示順序は、Unityにより管理されるSiblingIndexによって決定されてます。
このSiblingIndexは、例えばContent以下の兄弟オブジェクトの場合、Unityのエディタ上の見た目そのまま、Hierarchyの表示順に、0から順に振られています。
 
例:Hierarchyがこんな感じとした場合のSiblingIndex

...
 └ Content // ScrollViewのContentとか
  ├ childObjA // 0
  ├ childObjB // 1
  ├ childObjC // 2
  └ childObjD // 3
...

Grid Layout Groupの1セルの表示順(Hierarchyの配置順)はSiblingIndexにより決定しています。
ですので、セルのソートを判定したい場合は、現在のSiblingIndexを逆順で取得(GetSiblingIndex)し、
それを現在のセルの配置順に上書き(SetSiblingIndex)していくことでセルの表示順を反転させることができます。

例:すべてのセルのSiblingIndexをReverseしてセットし直した状態のHierarchy

...
 └ Content // ScrollViewのContentとか
  ├ childObjD // 0
  ├ childObjC // 1
  ├ childObjB // 2
  └ childObjA // 3
...

参考資料

Grid Layout Groupの使い方

https://qiita.com/t_Kaku_7/items/588fada25cf2d519589d
https://tech.pjin.jp/blog/2016/08/30/unity_skill_3/

SiblingIndexについて

https://forum.unity.com/threads/order-of-elements-in-layout-groups.267668/
https://qiita.com/T_2/items/69c22e649441055d2329

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

UnityのAndroidアプリにAdMob 広告を入れたときのエラーやトラブル解決法

はじめに

現在開発中のアプリでNendAdやUnityAdsを入れているのですが
調べているとAdMobが結局相場が良いとの情報があったので試しに入れることにしました。
そこで色々トラブルありましたので備忘録としてここに残しておきたいと思います。

環境や利用するもの

Unityバージョン:2019.2.12f
Android周りのモジュール(NDKやらAndroid周り)も一緒にインストールしています。

導入方法は基本こちらを参照してください。
プラグインは「v4.1.0」というものを取り込みました。

取り込み時に出るウィンドウ

後で書いているので詳細に覚えていないのですが、取り込むと初めになにかポップアップが出てきました。
それを実行すると全く画面がすすまなかったのでキャンセルしてしまいました。

あとになって考えると出たそのウィンドウはプラグインによって追加される項目

Asset→Play Services Resolver→Resolve or Force Resolve

という項目の設定だった思います。

どうやらこちらはプロジェクトにある他のプラグインで使われているNDK?やJDK?などのライブラリの依存関係を解決するものなようです。

ひとまずこのResolveを実行して上記のサイトの手順に沿ってバナーを置いてみます。

Android実機で起動できたが広告が出ない

配置したはずの広告がでませんでした。なぜ?
どうやら単純に設定が足りなかったようです。

Assets→Google Mobile Ads→Setting

を実行するとInspectorに各設定が出来たようです。

またInspectorのGoogle AdMobの「Enabled」を有効にして
各プラットフォームの「App ID」を設定しました。

しかし、それでも広告は出ません。

そういえば最初のエラー

ふと、プラグインの時に出てきたあのウィンドウ重要だったんじゃ…と思いました。
そして

Asset→Play Services Resolver→Resolve

を実行しましたが大した表示もでず、先程と同じです。

念の為こちらも押してみました。

Asset→Play Services Resolver→Force Resolve

すると、さっき出てたウィンドウが出てプログレスバーが動いています!

ちょっと待つと無事に終わってデータが更新されたようです。
「これで動くぞ~」と思ったらここからエラー地獄の始まりです。

海外フォーラムにもあった内容

色々巡回しすぎて忘れてしまったのですが、海外のフォーラムなどでも同じ現象が出てるとありました。
ただ同じ環境の人が少なく、参考になるものはありませんでした。

AdMobプラグインが新しいから?

もしかしてAdMobプラグインに対してUnityのバージョンが古いから出るのかなと思いました。

ただUnityを上げるとほかにも不具合が出そうだったので、利用しているバージョンの2019.2.12の少し後に出ているプラグインのバージョンを入れてみました。

しかし、残念ながら直らず…

JDKで直るかも

どっかのサイトでJDKも関係していて最新を入れたら直った説を発見!

最新版のJDKをオラクルからダウンロードし、

Preferences→ExternalTools→JDK Instakked with Unity

のチェックを外して
インストールしたパスに追加しましたが特に変化はなしでした。

競合しているってことは?

「play-servicesほにゃらら」というファイルが競合している(複数ある)ため
先程の「Resolve」して依存関係を解決する必要がある
的な発言を見つけました。

そういえばNendっていう広告、Android環境でも使えてたってことはここに競合の元があるんじゃ?

さっそくエクスプローラで検索してみたら…ありました!
しかもNendフォルダの中!

まだ完全移行ではないので残していましたが
使わなくなる予定なのでさくっと削除し、念の為Resolveしたところエラーは消えました。

いざ、実機に送ってみるとまたエラー

地味に初歩的なエラーでちょっと時間を掛けてしまいました…
Keyデータのパスワードが全角半角が間違っているのが気づかず何回も実行しようとしたんですね。

もうエラーが多すぎて疑心暗鬼がMAXになってたんでしょうね(^_^;)

とりあえずそれをクリアしたら無事広告が表示されました!

大まかな解決法

紆余曲折あって解決したこともあって複雑に書いてしまいましたが
おそらくこんな感じで直ると思います。

  1. GoogleのAdmobページで広告などを用意
  2. GoogleAdsプラグインを取り込む
  3. Assets→Google Mobile Ads→Settingで設定
  4. Nendなどの競合しそうな広告プラグインを削除

ただこの対応に2日掛けているので
Googleで設定した広告が有効になるには数日かかることもあるらしいので
少し待たないと動かないかもしれません。

最後に

NendやUnityAdsみたいに簡単に入ると思ったら意外に大変でした…
iOSはまだ試していませんがまた何かトラブルがあればまた追記なり、記事にして共有したいと思います。

同様の問題で苦労している人などのためになれば幸いです。
ではここまでお読みいただきありがとうございました。

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

UnityのScriptable Render Pipeline(SRP)でライトの情報を取り出すメモ

前回、SRPの最初の導入を行ってみました。
https://qiita.com/Shibash/items/d6bdfc956859138ad6ff

前回の結果画面を見てみると、のっぺりしていて立体感がありません。
なので、立体感を出すために、簡単にライティングをしてみます。

Unityが用意しているUniversalRPは、いつも通りSpotLightやDirectionalLightを利用することが出来ます。

同じように、UnityのDirecitonal Lightの情報を使うようにして、ライティングを行わせるには
どうすれば良いかメモ書きです。

Unityバージョンは2019.3.0b6
com.unity.render-pipelines.core 7.1.1
com.unity.render-pipelines.lightweight 7.1.1
com.unity.render-pipelines.universal 7.1.1

シェーダの改良

ライティングを行うために、前回のシェーダの改良を行います。
長くなるので内容は割愛しますが、少なくともシェーダにライトが持つ方向などのパラメータを渡す必要があります。

コードを確認

・ライトの情報をシェーダに渡している箇所はどこなのか?

ForwardLightsクラスのSetupMainLightConstants関数がその処理を行っていそうです

Vector4 lightPos, lightColor, lightAttenuation, lightSpotDir, lightOcclusionChannel;
InitializeLightConstants(lightData.visibleLights, lightData.mainLightIndex, out lightPos, out lightColor, out 
lightAttenuation, out lightSpotDir, out lightOcclusionChannel);
cmd.SetGlobalVector(LightConstantBuffer._MainLightPosition, lightPos);
cmd.SetGlobalVector(LightConstantBuffer._MainLightColor, lightColor);

cmdはCommandBufferで、このクラスを通してシェーダの変数を渡すことが出来ます。
CommandBufferPoolというクラスに、CommandBufferの管理を行わせていて
プールして使いまわすようにしているようです。

・ライトの情報を取得しているところはどこなのか?

LightData構造体が、VisibleLightという構造体を持っています。
UniversalPipelineのInitializeRenderingDataで受け取っていてCullingResultsから取り出せるようです。

var visibleLights = cullResults.visibleLights;

これは前回の記事で行った、カリング結果にデータが入ってきています。
https://qiita.com/Shibash/items/d6bdfc956859138ad6ff

ライティングの方向はShadowUtilsのSetupShadowCasterConstantBuffer関数で渡しているようです。
行列の内部に含まれるデータの扱いは、以下の記事が参考になります。
http://marupeke296.sakura.ne.jp/DXG_No39_WorldMatrixInformation.html

Vector3 lightDirection = -shadowLight.localToWorldMatrix.GetColumn(2);
cmd.SetGlobalVector("_ShadowBias", shadowBias);
cmd.SetGlobalVector("_LightDirection", new Vector4(lightDirection.x, lightDirection.y, lightDirection.z, 0.0f));

まとめ

以下の2つを押さえておけば、ライトの情報を取り出してシェーダに渡すことが出来ました。
・CommandBufferを使って、シェーダのグローバル変数を渡すことが出来る。
・ライト情報はCullingResultが持っている。

Directionalライト
image.png
Directional、Spot、Pointライト

参考サイト

ほぼほぼ自分メモになってしまいましたが
以下に、SRPを利用したライティングがしっかりまとめられているので、こちらを読めば色々出来ると思います。
https://catlikecoding.com/unity/tutorials/scriptable-render-pipeline/lights/

↓ちょっと新しい版
https://catlikecoding.com/unity/tutorials/custom-srp/directional-lights/

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