- 投稿日:2019-12-11T23:01:40+09:00
Geometry Shaderのサンプルを解読する(初級編)
はじめに
- Unityでかっこいいエフェクトを作りたい!!
- keijiroさんのエフェクトがめっちゃカッコ良いから参考にしたい!!
- でもテクニックが上級すぎてソース見ても全然わからない!!!!(ごめんなさい)
という経験、Unity勢のみなさんなら一度は通ったことがあるのではないでしょうか…?(暴論)
この記事では、(主に自分が)そんな状態を脱するべく
エフェクトの中でよく使われている Geometry Shader というシェーダーを理解して、
カッコいいエフェクトを作れるようになるための一歩を踏み出します!※ 備忘録的に書いているので、説明を一部省略してしまっているところがあります。ご了承ください。
Geometry Shader とは
- 簡単に言うと、プリミティブ(メッシュを構成する基本形状)の増減や変換ができるシェーダーです。
- Vertex Shader の後、Fragment Shader の前に実行されます。
- メッシュの頂点数をいじれたり、頂点を増やしてポリゴンにしたり、増やした頂点の位置や回転などを制御することができるため、エフェクトの幅をかなり広げることができます。
以下の記事や本の説明がとてもわかりやすいので、ぜひご一読ください。
サンプル
今回はサンプルとして、keijiroさんの StandardGeometryShader を拝借させていただきます。
すでにもうなんかカッコいいですよね。この時点でも結構シンプルなものになっていますが、シェーダーの中で Deferred Rendering を行なっているので、そこも削った最小限のサンプルへと改変します。
- ※ Deferred Rendering の説明はここでは割愛しますが、以下の記事がわかりやすいです。
コード
改変したコードを以下にアップしています。
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種類の構造体を用意します。(今回はどちらも
position
とnormal
のみ)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
- 最大で出力する頂点の数
maxvertexcount
は3 * 1(上面) + 4 * 3(側面) = 15
とします。
また、今回はプリミティブごとにそれぞれ異なった押し出し量にしたいので、
引数に各プリミティブのIDuint 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
の値に合わせて、
表面の三角形がボコボコ動くことが確認できます。(最初の状態と色味以外何も変わっていないように見えますが、Geometry Shaderでこのようなエフェクトが作れることがわかったかなと思います…!)
おわりに
- Geometry Shaderの簡単なサンプルを解読してみました。
- (初級編と言いつつボリュームが多くてすみません…)
- (そして勝手に解説してしまってkeijiroさんすみません…)
- 次回は、中級編としてもっと複雑なGeometry Shaderのエフェクトを解説する予定です!
参考にしたリンク集
- 投稿日:2019-12-11T22:47:56+09:00
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.html2、写真を撮影する。
2-1、機材
持ち運び最優先の機材になります。
この組み合わせなら三脚が必要なく一脚もコンパクトな為、出張道具の隙間に紛れ込ませることも可能です。
一応パノラマとして繋げられるので十分な撮影は出来ていると思います。
ただし、天頂と真下の撮影が出来ない事、レンズの位置を保持する精度が落ちる事が本物のパノラマヘッドより劣ります。
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、撮影手順
1、フォーカス、露出関係を合わせる
ピントが合って欲しい辺りに合わせます。
魚眼レンズの場合、ピントが合う範囲が非常に広いためほぼ全域で合うと思います。露出関係の参考値は下記になります。
・シャッタースピード最低でも1/30 sec
・F値 8.0
・ISO感度 200(最高でも400未満)
必要に応じて左から順に合わせます。
シャッタースピード > F値 > ISO
シャッタースピードでどうにもならなければF値、ISOと触ります。2、始点の目印になりそうな物を中央へ映す
液晶を見ながら始点の目印になりそうな物を中央へ持ってきます。
この場合は道を中央へ映しています。
3、方角を合わせる。
どちらの方角でも構いませんが、コンパスの周囲のダイヤルを回してNの位置を合わせます。
0度、60度、120度、180度(S)、240度、300度、360度(0度)
で撮影をする為、計算がしやすいように合わせておく必要があります。
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を起動して「ファイル→写真とビデオを読み込み」を選択します。3-2、Shiftを押しながら始点と終点を選択
読み込みが終了し、写真が表示されたら写真を選択します。
撮影の際道を中央へ配置したので、道を目印にします。
最初と最後にほぼ同じ道の絵が有るので、画像で分かると思います。
Shiftを押しながら始点と終点を選択します。
この画面ではIMG_1911~IMG_1920になります。
本来であれば写真7枚、ブラケット撮影を行っていれば21枚の写真を選択する事になります。
(レンズやパノラマヘッドの内容が違うため、写真枚数は画面と一致しません)
3-3、パノラマツールで合成します。
「2」で選択した画像を右クリックすると「写真結合→パノラマ」とかがあると思います。3-4、横長画像をフォトショップで正方形にする。(PhotoshopのScript使用)
このサイトのスクリプトを参考に改造しました。
http://sabaten.com/blog/182/内部的には
1、元画像の解像度を16384*4096へリサイズ
2、解像度8192*8192の画像を新規作成
3、元画像の左半分8192*4096を新規画像の上半分へ貼り付け
4、元画像の右半分8192*4096を新規画像の下半分へ貼り付け
5、保存
6、全画像を閉じる
を行っています。下のコードをフォトショの"C:\Program Files\Adobe\Adobe Photoshop 2020\Presets\Scripts"
へ入れると下記みたいな感じで使いやすくなります。
全天球ファイル変換.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-bottomSphealImage.shaderShader "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」の行を有効化するかします。後はUNITYへ組み込めば表示されます。
RenderQueueはシェーダーへ組み込んだものの方が優先されます。
マテリアルの設定ではなくシェーダーで必要に応じて調整してください。
今回作成したワールドでは表示用のエフェクトの都合でレンダリング順をかなり後の方にしています。エディター画面上ではマテリアル設定が優先されるので、編集の都合に合わせて設定してください。
- 投稿日:2019-12-11T21:41:20+09:00
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フォルダ以下に配置
UnityのApi Compatibility Levelを.Net 4.xにするのを忘れないように
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のスクリプトを編集しアップロードしてくださいAmazonGameLiftClientクラスの初期化をする
Lobby.csconfig = 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.cspublic const int SendTest1 = 10; public const int SendTest2 = 11; public const int RecieveTest1 = 31; public const int RecieveTest2 = 32送るイベントのサーバー側定義
server.jsconst 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を作成しビルド。
以下は別々のアプリで起動し
ルーム作成→ルーム検索→ルームへ参加→イベントを送る
と試したところ
見栄えよくないですが
「誰かが送ったメッセージをトリガーにし、UDPでつながっているすべてのユーザーにデータを送る」
の目標は達成したので今回はここまで再度の注意点
作成した
- スクリプト
- フリート
- エイリアス
に関して使わないときは削除するようにしてください。
でないとお金がかかっちゃうので。おわりに
走った説明になっちゃいました。
所感としては確かに簡単にリアルタイム通信ができると感じたが、
無料で気軽に試せないのがつらいところこれから遊びの部分を作って行こうと思いましたが、金額がかかっちゃうので気が向いた時にでも
明日は @e73ryo さんのUIElementsで開発するときの問題と解決です!
- 投稿日:2019-12-11T21:04:17+09:00
【Unity】剣の軌跡のつくりかた
はじめに
こんにちは、中二病のZeniZeniです。
ゲームで剣を出すなら、かっこいい軌跡を出したいですよね?
色々と調べると、剣の軌跡の作り方はいろいろな方法があり、それぞれに長所・短所があります。
この記事では、それらの剣の軌跡の作り方をまとめてみようと思います。
VRでの使用を目的に調べていたので、VRに適しているかどうかの評価が多分に含まれます。TrailRendererを使う方法
参考資料
一番オーソドックスなのは、Trail Rendererを使う方法だと思います。(記事も多いです)
- ユニティちゃんでわかるVRchatのためのunity教室 第12回 Trail Rendererで軌跡を描こう トレイルレンダラー実装編
- ケーキのPC情報集会所 【VRChat】トレイルレンダラー(Trail Renderer)を使って軌跡を描いてみよう♪♪AnimationEventやScript使わなくても、簡単な設定でそれっぽいのができるのは素晴らしいです。
Boothにもクオリティの高い作品が多く出展されています。
- 剣向け汎用トレイル Black.Cat's様作
- 霊刀睡蓮[屈折トレイル付き] オニガワラインダストリ様作具体的な使用方法は上記サイト様を参考にしてください。
注意点 ねじれ問題
注意点なのですが、TrailRendererにはねじれ問題が存在します。
ねじれ問題とは、下図のように、Trailの折り返し時に、軌跡がぐちゃっとしてしまう問題です。
原因は
- ノード間の感覚が狭い
- トレイルの幅が広い
ことであり、これらの調節である程度は改善できますが、完全にねじれを消すことは難しいです。
こちらの動画で詳しく紹介されています。
【Unite Tokyo 2018】誘導ミサイル完全マスター一般的な3Dゲームで使用する剣の軌跡であるならば、アニメーションが固定なのでTrailのオンオフで割と簡単にごまかせるのですが、VRだとそうはいきません。
対処法
ねじれ問題の対処方法なのですが、二種類あると思っています。
一つは、上記動画で紹介されていますが、折り返し地点だけTrailを透明にしてごまかす方法です。
Shaderを自分で実装する必要がありますが、一つのTrailだけでできるのは楽でよいです。もう一つが、「剣向け汎用トレイル Black.Cat's様作」の実装法で、TrailRendererを大量に使用する方法です。
ねじれ問題は、トレイルの幅が広いと顕著に表れるので、幅の狭いTrailを大量に用いればぐちゃっとした軌跡ではなくなります。スクリプトからメッシュを生成する方法
こちらの記事で紹介されている方法です。
Unityを使った3Dゲームの作り方(かめくめ) Unityでスクリプトから剣の軌跡を作成し表示する
スクリプトでメッシュを生成しているので、剣を振るアニメーションがどんなものでも調整いらずで軌跡を表示できます。ParticleSystemを使う方法
1番汎用性が高く、個性を出しやすいのはこの方法だと思います。
ただParticleSystemを使うといっても、方法はいろいろとあります。剣の軌跡エフェクトを作って、アニメーションに合わせて発生させる方法
これは非VRのゲームで多く採用される方法だと思います。
下のGIFのようなエフェクトを剣を振るアニメーションに合わせて発生させます。
このようなエフェクトは下図のような連番テクスチャを作成して、ParticleSystemのTexture Sheet Animationの設定をすることでできます。
この連番テクスチャはAdobe AfterEffectを使用して作りました。
以下の動画が参考になります(英語字幕)。
Game Effect Tutorial - Slash - Part 1/4 - DucVu FX
アニメーションに合わせて発生させる方法ですが、Animation Eventを使用することでできます。
Animation Eventの詳しい方はこちらを参考にしてください。
[Unity] Animation Eventを使いこなそう!剣から細かいパーティクルを出して、軌跡っぽく見せる方法
こちらの画像みたいなことをします。
動画はこちら
https://youtu.be/mU_uUo__vkw実装方法
剣から発生させたいParticleSystemを以下のように設定します。
グローバルモジュール内のSimulationSpaceをWorldにします。
これがLocalだと、発生したパーティクルは剣との相対距離が一定のままになるので不自然になります。
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にするとよいです。
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番使い勝手が良くて好きですね。
- 投稿日:2019-12-11T18:58:05+09:00
Unityを使って学園祭でVRスプラトゥーン を自作して展示した話
この記事は Unity #3 Advent Calendar 2019 ・ mast Advent Calendar 2019 の8日目の記事です!
ごめんなさい
!!!3日も遅れてしまいました...(一番下の展示した話は絶賛執筆中
TL;DR
11月の頭に学園祭で下のようなVRゲームを展示した。
【5C棟511でプレイ!】
— RE:REAL@雙峰祭5C511 (@rereal2019) November 1, 2019
初めまして!
RE:REALでは雙峰祭内の芸術祭1日目2日目に5C棟511教室にてゲーム作品を展示します?
実際の511教室を、VR空間上に再現!教室で普段できないことができます✨
みんなのプレイ結果が #playrereal にあがるのでチェックしてね✌︎
↓開発中の映像( ∩'-'?⊂ ) pic.twitter.com/geJsZvXJJAその技術まとめログを残して置きたくまとめた。
Gitのコミットログをたどってトピックを時系列で書いてく。構想
まずは、 作りたいもの・テーマ・要件 を決めるところから始めた。
2019年7月の時点で、作りたいものは「ゲーム」ということだけが決まっていた。
あとは、どんなテーマでどんなゲームを作るかを考えて行くだけであるが、そのまま夏休みを消化してしまった。9月の終わり、「このままでは学園祭間に合わねぇ」となり、構想を急ピッチで固めることとなった。スマホやゲーム端末でどこでも簡単にゲームをできるからこそ、学園祭の「その日」「その場所」でしかできないものを目指すことにした。
作るゲームは「VRスプラトゥーン(仮)」、その名の通りVRでスプラトゥーンをするというゲームである。
アイデアが思いついてしたのは、まずは「先人はいないか」 「二番煎じではないのか」をサーベイしていくことに、そんな中見つけたのは以下のインターフェースである。#スプラトゥーン の新しいインタフェース作った pic.twitter.com/rqedtePWAY
— kougaku (@kougaku) June 20, 2016ツイートにあるインターフェースの詳細は こちら
これは新しいインターフェースであり、ゲームシステム自体は任天堂の端末で動いてるので、ゲームシステムから自分で実装していけば、二番煎じならない と踏み開発にとりかかることにした。
部屋が借りらたので、現実と全く同じようにモデリングした空間をステージにすることにした。
現実と 同じ見た目 の空間をステージにすることで、 「その日」「その場所」でしかできない という目指すべき点を満たした。同時に、メディアアートとしてのコンセプトが完成した。目に映っているのは現実であるが、実際には現実では無く
体験している本人からは、現実の物理的な世界でできないことが、
目に映っている現実と同じように見える情報的な世界で、実現できるゲームを作りたいといっても、制作するゲームは展示のためで、プレイしてもらう以上、自己満足は避けるべきである。
開発において、自己満足ルートに入らないように、先に要件を練って決めておくのは大切だった。Unityで簡単お手軽VR
2019/9/29
Oculus無ェ、Viveも無ェ、お金もそれほど持って無ェ
ですが、VRゲームを作っていく。Oculus RiftもGoもQuestもHTC Viveもありませんが、スマートフォンがあれば十分。Unityの
Player Settings > (Android)XR Settings > Virtual Reality Supported
にチェックを入れて、下のリストにVRプラットフォームを追加するだけ
今回は、Cardboard
を使いました。これで、Unityのカメラに映っている空間が自動で左目用、右目用に分かれてレンダリングされ、カメラの向きがジャイロセンサーに追従するようになった。
↑ これはスマホの画面です。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/479eda46afaadfb55ae4UnityでVroom開発
https://qiita.com/TakaoWing/items/17758537d2a848f24191
エレコム VR リモコン Bluetooth [VR/AR ヴルームSDK対応] ブラック JC-VRR02VBK
※PRとかステマとかではなく 普通に良かったUnityへの導入は上のQiitaの記事を参考にした。
実際にAndroid端末でVroomコントローラーを使うのには、Androidの位置情報のパーミッションを許可する必要があった。
部屋の計測
2019/10/15
実際の部屋がステージなので、モデリングするために計測をした。サクサクっと部屋の計測をしたかったのでレーザー距離計を導入した。1.測りたい起点にレーザー距離計を当てる
2.ボタンを押してレーザーポイントが測りたい終点を確認
3.もう一度ボタンを押すとディスプレイに距離がでる
これだけ部屋の大体の大きさがわかった。
BOSCH(ボッシュ) レーザー距離計 GLM30 【正規品】大体の形で距離を測っておいて、測れない部分は全体の長さから調整して割り出した。
部屋のモデリング
2019/10/21
Autodesk Fusion 360
を使って、実際に測った寸法どおりにモデリングした。壁や床の色はテクスチャでつけるのでモデリングでは形だけを作っていく。細かいパーツはとりあえず後回しで、80%でも良いからゲームをプレイできるように、大まかなに部屋を作っていく。素材の購入
2019/10/22
概形のモデリングを終え、Unity内に壁と天井と床を作成したが、壁だけだとどうにも寂しい。教室の最低限の要素として、黒板
と蛍光灯
は必要ではありそう。だが、計測してないしモデリングの手間を考えると、教室のアセットを買うべきだと思って購入。このアセットから黒板と蛍光灯のモデルを拝借し、サンプルシーンから部屋のライティングがどうなっているかも確認した。ライティングがよくわからない
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/001302Unityで、PBRなライティング環境をセットアップしてみよう
http://techblog.sega.jp/entry/2019/04/25/100000「Unity Japan Officeプロジェクト」早速DL。https://t.co/0IvHjiUQWV
— 龍 lilea (@lileaLab) July 9, 2019
これはすごい。
ため息が出る美しさ。
しかもプロジェクトデータも公開されるとか! pic.twitter.com/nDhpL56xKK実際に作成してできたのはこんな感じの教室
まだまだ改善の余地はあるが、まだゲームのシステムには手をつけていないので作り込みはシステムができてから...InkPainterの導入
2019/10/22
スプラトゥーンはインクを飛ばしてステージを汚していくゲームですが、どうすればUnityで実装できるでしょうか...。Unityならアセットに頼って行くのがよさそうなのでアセットを探します。制作するゲームでは、いーす さんが作成した InkPainter を使います。InkPainterはテクスチャが設定されてないと塗れない
2019/10/22
インクを付けたいMesh
にはMesh Collider
とInkPainter.cs
アタッチする。MousePainter.cs
をMain Camera
にアタッチするとマウスでクリックしたところにインクが付く(マウスから直線上にRayを飛ばして、最初にオブジェクトと交差する交点にインクが付く
しかし、テクスチャが未設定だとインクが付かないので、Albedo
にテクスチャをセットしておく。
InkPainterはモデルがUV展開されてないと塗れない
2019/10/22
ふぇ〜、壁をクリックした瞬間全部塗られてしまった...
この時点で「テクスチャがめちゃくちゃ小さくて、一瞬でテクスチャを全部塗りつぶしてしまった?」とか考えて、こねこねした。
この記事をみるとどうやらモデルがUV展開されていないと一回で塗り潰れると書いてある。
石膏像にInk Painterでペイントする【Unity】
http://bibinbaleo.hatenablog.com/entry/2017/12/10/164535Autodesk Mayaを使ってUV展開した。この際、展開するUV座標の範囲は
X:0.0-1.0 Y:0.0-1.0
じゃないといけないので注意。
モデルが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/161729Texcoordを取得してその位置にインクを塗る、といった処理をしているのですが、見ての通り正常に描画されない場合、Texcoordが常に0ベクトルを返しています。
解決するのはものすごく簡単です。メッシュのインポート設定でRead/Write Enabledにチェックを入れるだけです。とりあえず、 メッシュのインポート設定でRead/Write Enabledにチェックを入れる ことで塗れない問題は解決した。
InkPainterでペイントのサイズを補正できるようにした
2019/10/25
一通りインクを塗れるようにして、Android用に書き出して試し塗りをしてみると、床/天井と壁でブラシのサイズが違うことに気づいた。原因はわかっている。モデルをUV展開したときの縮尺が統一されていないからである。
モデルの縮尺を合わせるコストの方が高そうなので、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.csusing 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/8eee1de552601a8a8f1fUnity-今更ながらRigidBodyのCollision Detectionについて表にまとめてみた
https://qiita.com/take_shi/items/fea7304fe9868a74d561どうやら、速度が速い場合にはデフォルトの判定は効かないらしく
Collision Detection
を変更する必要がある。
CollisionPainter.cs
にはOnCollisionStay
が実装されているので、Sphereが壁や床にあたっている間に呼ばれる。CollisionPainter.cspublic 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.htmlpublic static void Destroy (Object obj, float t= 0.0F);
パラメータ obj 破壊するオブジェクト t オブジェクトを破壊するまでのディレイ時間 DestroySphere.csusing 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
に、「オリジナルのテクスチャ」と「塗ったあとのテクスチャ」を比較して、変化があったピクセルの数を計算するプログラムを追加した。InkPainter.csにもともとあった
SaveRenderTextureToPNG
関数を参考にプログラムを作成。
PaintSet#mainTexture (Texture)
がオリジナルの画像でPaintSet#paintMainTexture (RenderTexture)
がインクの付いた画像になっている。Texture
とRenderTexture
の内容は単純比較できなさそうなので、どちらもColor[]
に変換して単純に色を比較した。この処理はむちゃくちゃ重いです。Texture から Texture2D への変換
http://nakamura001.hatenablog.com/entry/20171012/1507810457InkPainter.cspublic 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.csprivate 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/SphericalImageCamUnityの基本機能だけを使ってSceneのキューブマップ(全天球画像)を作る
https://qiita.com/ELIXIR/items/c71ee67eb259bfa7d2c7Unity でステレオ VR 動画を作成する (ほぼ完全 (?) 版)
https://qiita.com/tan-y/items/941de5c8bc3309f835d5Unity Recorder の使い方
https://qiita.com/tan-y/items/644760a18484cbe71d43実際には下のQiitaの記事のプログラムを参考に、スコアが計算されたらカメラに映っている画像をキューブマップに落とし込んで、Shaderを使ってEquirectangularな画像を生成/保存できるようにした。
Unityでカメラのequirectangular画像を作成してみる
https://qiita.com/mechamogera/items/0b47e5947f1eee2467daVirtualController.csprivate 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 Settings
のAlways Included Shaders
にプロジェクトに追加したConversion/CubemapToEquirectangular
を追加しないとAndroid上で動作しないので注意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
Unity Native Gallery Plugin
を使うとユーザー領域にデータを保存できるようになるので導入、GitHubのページからNativeGallery.unitypackage
をダウンロードしてインポートで導入完了。あとは、保存するプログラムを書き換えるだけで終わった。
【Unity】iOS の写真や Andoid のギャラリーに画像や動画を保存できる「Unity Native Gallery Plugin」紹介
http://baba-s.hatenablog.com/entry/2017/12/26/210500VirtualController.csbyte[] 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/2ed2b01f2656fc50da8cCloud Functions for Firebaseが最高だった話
https://qiita.com/HALU5071/items/e43729ac5b06b0506fbemultipart/form-dataをアップロードに対応
2019/10/31
サーバーにファイルを上げるとなるとPOSTメソッド+multipart/form-data
でアップロードすることになる。POSTメソッドを叩けるエンドポイントをFunctionsに実装した。基本は公式のドキュメントを見ながら、実装例を参考にしていく。ポストしたデータはStorageに保存されるようになっている。Cloud Functions > ドキュメント > ガイド HTTP関数
https://cloud.google.com/functions/docs/writing/httpCloud Functionsから、Storageにアップロードする
https://shuheitagawa.com/upload-to-storage-via-cloudfunctions/FirebaseのHostingとCloud Functionsを利用してStorageへファイルをアップロードするデモアプリケーション
https://qiita.com/rubytomato@github/items/11c7f3fcaf60f5ce3365index.jsconst 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/6fcc770ea89fe885b8bdUnityWebRequest
https://docs.unity3d.com/ja/current/ScriptReference/Networking.UnityWebRequest.htmlVirtualController.csIEnumerator 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/6b269f61152e9f336c35index.jsconst 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=jaFirebase Cloud Functions, Realtime DatabaseでCRUD REST WebAPIを作る
https://qiita.com/devnokiyo/items/26de016b0baf60b26c90上の記事を参考に、Tweetのレスポンスとともにデータを記録。
push
でJavascriptのObjectの書き方で記録できる。index.jsconst 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
になるとゲームが始められる。(文章で書くと説明が長いのでこのシステムは良くないと今思っている...メモリ使用が800MBを超えるとアプリが落ちる
2019/11/1
アプリを終了せずともゲームをスタート/ストップできるようになったのだが、どうも3,4回目をプレイして塗りを計算したあたりでアプリが落ちる。コードを見る限り、プレイ回数に依存して配列外アクセス、nullアクセスして、NullReferenceException
とかではなさそうだった(今思えばログ見ればよかったのでは...。とりあえずこういった問題には、Profilerを使う。Android StudioにはAndroid Profilerがあるので、デバイスをmacにつないだままプレイしてCPUやメモリの状態を観察すると、ゲームが終了するごとに約200MBぐらい増えている。
800MBあたりを越えた時点でアプリが終了してメモリがパージされている。これはつまりメモリリークってやつですね。ゲームが1回終わるごとにアプリをリセットすれば良いが、忘れたりでもしたらプレイ結果が消えるので、これは解決するべき問題。(人間がシステムに対して行わなければいけないことは最小限にしたい
メモリリークに対してあまり詳しくないのでサーベイ。調べると、Texture系はnewして使わなくなったらDestroyしようということなのでプログラムにDestroyを追加
InkCanvas.csDestroy(_renderTexture); Destroy(newTex); Destroy(texture2D);cubemapは1280x1280x6のテクスチャ、renderTextureは4096x2048でどちらもRGB24なので、これだけで約53MBもあるわけだ...
VirtualController.csDestroy(cubemap); Destroy(renderTexture); Destroy(convMaterial);Unityでメモリ節約
https://qiita.com/consolesoup/items/769fdf9e5748aa3e7f05【Unity】開発中のアプリがメモリリークで強制終了するようになった時に対応したこと
http://baba-s.hatenablog.com/entry/2017/06/29/100000Unity RenderTextureのメモリ確保と解放タイミングの落とし穴
https://www.shibuya24.info/entry/rendertexture_mem_allocUnity コンポーネントがDestroyされても、オブジェクトがGCで回収されないかもしれない話
https://qiita.com/toRisouP/items/4574a30622f43ddbde79UnityでTextureを明示的に破棄
https://qiita.com/tempura/items/b87eb07568d974664671玉が一定範囲を越えたら消えるように
2019/11/1
窓にインクをつけるかつけないかを悩んで、つけないことにしたので窓に当たり判定をつけなかった。だから、インクボールが窓の外に出たらインスタンスが残ってしまう。重力が設定されているので、大量にインスタンスが残るとゲーム自体のパフォーマンスが悪くなってしまうので、原点からの距離を測って一定範囲外にでたら消すようにした。unity > 物体がある範囲外に移動した時に消えるようにする > Destroy(gameObject);
https://qiita.com/7of9/items/f9843c6668eb269678deDestroySphere.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.cspublic 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.jsexports.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; /*-------- 省略 -------- */ }UnityXRで動き回るにはカメラの親オブジェクトを移動させる必要がある
2019/11/1
ジョイスティックの上入力と下入力の機能に空きがあるので、前と後ろに移動できるようにしてみる。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を動かすことにした。
VirtualController.csvoid 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/プレイヤーネームの登録を作成
2019/11/3
デプロイしたサイトに新規ゲームのためにプレイヤーの名前を登録できるフォームのあるページを作成した。念を入れてプレイ時間とインクの塗りをリセットするか?を変えられるようなInputも作成した。プレイヤーには名前を入れるだけのフォームに見えるように、フォームのSubmitボタンを上にして、下の方にオプションの項目を並べている。ユーザーネームを登録できるエンドポイントはFunctionに実装しておく。もともとあったデータをDatebaseから取得して処理を進めたかったので、
await
を使えるようにfunctions.https.onRequest
の引数の関数には、asyncをつけている。async/await 入門(JavaScript)
https://qiita.com/soarflat/items/1a9613e023200bbebcb3index.jsexports.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%88index.jslet 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.jsexports.getRank = functions.https.onRequest((req, res) => { cors(req, res, async() => { /*-------- CROSを許可したい処理 -------- */ }); });Ajaxを使ってランキングを表示する部分(Javascriptの書き方が混ざっていてやべぇなw
rank.htmlvar 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.jsexports.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/53627Firebaseの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]反省
絶賛執筆中
最後に
UnityとFirebaseは神だった
- 投稿日:2019-12-11T16:05:47+09:00
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 GuidelinesVR向け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空間でボタンを押す」方式。
部屋内での移動はテレポート方式で、ワールド移動は「VR空間にあるオブジェクト(球)を掴んで頭に持ってくる」ことで移動ができる形でした。
そのほか特徴的なものでは、Oculusの「First Step」や「First Contact」がありますが、これらもほぼ同様の操作体系だった記憶があります。
2016年頃にリリースされたVRタイトルは全体的に「The Lab」をお手本にしたものが多かったように思います。2016年後半 HoloLens1国内発送開始
このころはARの盛り上がりもあってか、ハンドジェスチャーやモーションによる入力が検討され始めた時期でもあると感じています。
ハンドジェスチャーについてはHoloLens1のAirTapが印象的でした。
Body-LockedなUIの検討や、Gaze入力が実用的に使われ始めたのもこの時期な印象です。
Gaze入力に関しては、3DoF-VRで有用な入力手段として、同時期からGearVRやGoogle Cardboardのようないわゆる「スマホVR」でも多く使われていた記憶があります。そして2019年...
そして今年までには多くのタイトルが「ポインター型2D-UI」を採用している印象があります。
VR配信や動画でよく見るものではBeatSaberが多いですね。
いつからか、SteamVRダッシュボードやOculusDashもこの形になっていました。
直感性には欠けますが、多くの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 さんです。よろしくお願いします!
- 投稿日:2019-12-11T11:18:50+09:00
Unityを使って手軽にスマホアプリからtoioを操作する方法(ライブラリ付き)
はじめに
Hiroshi Takagiと申します。普段は組み込みエンジニアをやっています。
toioで公開されている、技術仕様書とjavascriptライブラリを参考にしながら、Unityを使ってスマホアプリで動作するtoioの開発環境を作りました。
完成した。 pic.twitter.com/SNwwaIIcmn
— Hiroshi Takagi (@TkgHrsh) December 13, 2019技術仕様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.csusing 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.csusing 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をアタッチすれば準備完了です。
ビルドしてスマホに移す
この記事を参考にするとよいと思います。
iOSの話になってしまいますが、一点注意点です。
XCODEから実行する際に、下図のように、Infoに[Privacy - Bluetooth Always Usage Description]を追加する必要があります。これがないとアプリが起動できなくなります。
さいごに
うまく動きましたでしょうか?
あとはサンプルアプリケーションを参考に、自分のオリジナルのtoioアプリが作成できると思いますので、ぜひ使ってみてください。最新のtoioの技術仕様書を見ると、ver2.1.0に更新されているので、またアップデートしてご紹介したいと思います。
それではよいクリスマスをお過ごしください。
- 投稿日:2019-12-11T09:03:58+09:00
Unityでデスクトップマスコットを作ってみた
はじめに
はじめまして。木更津高専 Advent Calendar 2019 11日目担当の、わくと です。
初めてのQiita記事です。がんばります!概要
コーディング中・ブラウジング中に画面の右下あたりが寂しくなることはないですか?
そんなときにぴったりな、いつでも見守ってくれるデスクトップマスコットを作りました!開発環境
- Windows10
- Unity2019.2.14f1
使用したツール・モデル
デモ
ころねさんのデスクトップマスコット作ってみました?#戌神ころねMMD pic.twitter.com/CFdLYOP3rS
— わくと (@otukaw) December 11, 2019
まばたきとマウス追従をしています。実装
ボーン・モーフなどはアニメーションなどを使わず、すべてスクリプトで動かしています。
また、ウィンドウの透過などの処理はWindowsAPIを叩いています。モデル取り込み
まず、MMDのモデルをMMD4Mecanimを使ってインポートします。この記事がわかりやすかったです。
取り込む前にreadmeをしっかり確認します。表情
表情のスクリプトです。長いので簡略化してあります。
MorphController.csusing 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.csusing 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; } }特に難しいことはしていませんが、一定時間以上マウスが動かないでいると普通の表情に戻ります。
当たり判定は下の画像のようになっています。
おわりに・感想
頑張ればがんばった分だけ可愛くなるので作っていて楽しかったです。
簡単に作れるので、自分の推しで作ってみるのはいかがでしょうか?
まだまだ、待機モーションなど付け足したい機能があるので、これからも開発を続けていこうと思います!
なにかあったら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.
- 投稿日:2019-12-11T05:59:18+09:00
【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;参考サイト
- 投稿日:2019-12-11T05:14:35+09:00
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
orBIN
の判定を、intとして処理することで1byteずつASCIIで判定することなく行えます(つまり、enumで判定できます)ので、フォーマット部はintで行うことをおすすめします。JSON: 0x4e4f534a BIN: 0x004e4942glTFのJSON部処理
mebiusbox/gltf(Github) で公開されている以下の画像を参考にパーサを実装しました。
最初はglTFのjsonスキーマを読みながら実装をしていたのですが、このクイックリファレンスのおかげでかなり楽に行えました。メッシュ処理
メッシュには
Primitive
が配列として格納されているため、それをループで処理します。
Primitive
= Unityで言うサブメッシュを表します。先にvertex数分確保が必要ですが、Primitive1個目のPosition数*Primitive数で初期化しました。
ここでは、Position
とNormal
、TexCoord0
をこの基準で格納しています。
Indices
はPrimitiveの数分のジャグ配列を初期化し、それぞれ格納しています。実際にUnity上のメッシュにする際は、
subMeshCount
にPrimitive
の数をセットし、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);そのまま描画しようとすると座標系の違いにより左右反転して表示されるため、変換を行う必要があります。
ここで変換が必要なのはPosition
とNormal
です(TangentはVRMの仕様上、含まれないため省いています)
単純にPositionのx座標を反転し、IndicesをVector3として見立てた状態での、X座標とZ座標を入れ替えることで行いました。困ったこと
右手系座標を左手系座標へ変換する際のSpanのパフォーマンス
Accessorの値配列を得るために、Spanを用いることで高速化しようとしましたが、書き換え時のパフォーマンスがよくありませんでした
(1000ループで約3000ns)
MemoryMarshal.Cast
でfloat
にキャストした後、該当位置に書き込みなども行いましたが、まったくパフォーマンスがよくならず、頭を抱えました。
結果としては、現在のUnityでの.NET Standard
バージョンが2.0
であり、ランタイムに最適化が入っていないことによるパフォーマンス低下であり、一度通常の配列として持ち、それをポインタ経由でいじることでパフォーマンスがよくなりました。
そのため、事前に一定のヒープを確保しておき、そこに保持することで高速化することが出来ました。まとめ
実際に自分の手でローダを作ってみることで、glTF自体や、Unityの描画周りなどを知ることができました。
車輪の再発明ではありますが、パフォーマンスチューニングを含む様々な知見を得られたため、かなりプラスでした。まだ全体の実装が出来てないため、出来上がったらコード込みの詳細の記事を出そうと思います。
- 投稿日:2019-12-11T04:20:32+09:00
【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]); }コード解説
- セルをSelectし、全SiblingIndexを取得(GetSiblingIndex)します。
- 前項で取得したSiblingIndexをReverseし、逆順のSiblingIndexを取得します。
- セルを元の順番で順次参照し、前項の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
- 投稿日:2019-12-11T04:20:32+09:00
【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]); }コード解説
- セルをSelectし、全SiblingIndexを取得(GetSiblingIndex)します。
- 前項で取得したSiblingIndexをReverseし、逆順のSiblingIndexを取得します。
- セルを元の順番で順次参照し、前項の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
- 投稿日:2019-12-11T01:41:07+09:00
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になってたんでしょうね(^_^;)
とりあえずそれをクリアしたら無事広告が表示されました!
大まかな解決法
紆余曲折あって解決したこともあって複雑に書いてしまいましたが
おそらくこんな感じで直ると思います。
- GoogleのAdmobページで広告などを用意
- GoogleAdsプラグインを取り込む
- Assets→Google Mobile Ads→Settingで設定
- Nendなどの競合しそうな広告プラグインを削除
ただこの対応に2日掛けているので
Googleで設定した広告が有効になるには数日かかることもあるらしいので
少し待たないと動かないかもしれません。最後に
NendやUnityAdsみたいに簡単に入ると思ったら意外に大変でした…
iOSはまだ試していませんがまた何かトラブルがあればまた追記なり、記事にして共有したいと思います。同様の問題で苦労している人などのためになれば幸いです。
ではここまでお読みいただきありがとうございました。
- 投稿日:2019-12-11T01:14:44+09:00
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.htmlVector3 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ライト
Directional、Spot、Pointライト
参考サイト
ほぼほぼ自分メモになってしまいましたが
以下に、SRPを利用したライティングがしっかりまとめられているので、こちらを読めば色々出来ると思います。
https://catlikecoding.com/unity/tutorials/scriptable-render-pipeline/lights/↓ちょっと新しい版
https://catlikecoding.com/unity/tutorials/custom-srp/directional-lights/