この記事はtraP Advent Calendar 2022 30日目の記事です。
はじめに
こんにちは。Renardと申します。
私は最近VRChatに入り浸っており、結果的にこの様な記事を書くに至りました。
(@Renard_VRC までフレンド申請歓迎しております。おはなししましょう)
VRChatについて知らない方は@pikachu君の記事 に詳しく書かれていますのでご参照ください。
さて、先日私がいつものようにVRChatで遊んでいると、ワールド内に2048というゲームが置いてあるのを見かけました。
私はパズルゲームが大の苦手なので全く楽しくありませんでしたが、何となく「アバターにゲームを仕込んでみたいな」と思いました。
以上が動機となります。
早速本題に移ります。
この記事では何をするのか
VRChatのアバターは自分で好きに作ることができ、Unityを介してアップロードすることが出来ます。
本記事では、作成した自分のアバターに2048を仕込む方法を大まかに解説します。
作成したゲームはいつでも好きなときに出すことができ、また、フレンドも遊ぶことが出来ます。
Unityの操作方法やアバターのアップロードまでの手順などは解説いたしません。
また、本記事で取り扱うコードなどは完成済みの状態で全てGithubに公開してありますので、そちらもご一緒にご覧ください。
(実際にVRChatで動かすにはAnimationの設定をご自身で行う必要があります。動かしてみたいだけの方は、「Animationの設定」からお読みください。)
方針
VRChatにおいて、ワールドにはUdonというプログラミング言語を用いたスクリプトを含むことができるのですが、アバターには含むことができません。
しかし、アバターの肌や髪などの見た目を決める、シェーダーというプログラムは含むことができます。
シェーダーとは、例えばVRChatではアバターの肌の質感や陰影を出したり、発光させたりするのに使われています。
このシェーダーというものはとても自由度が高く、大抵の事はできるので、ゲームも作れます
(大抵のことが出来るという一例:SCRN-VRC/Language-Translation-with-Fragment-Shaders: EN to JP and JP to EN with transformer models (github.com))
実装1 - 値の保存を行う機構
シェーダーでは、変数に格納した値はフレーム毎に消えてしまいます。
2048を実装するにあたって状態の保存は必須なので、これでは困ります。
この問題を解決するため、カメラループと呼ばれる機構を作成します。
まず、RenderTextureを用意します。
FilterModeはPointに設定して下さい。
次に、CameraとQuadを作成してください
Cameraで設定する項目は以下の通りです
- Position
- Size
- ClippingPlanes
- TargetTexture
Cameraは画像の通りに設定し、QuadはScaleを0.02に設定してください。
以上でカメラループの作成ができました。
このレンダーテクスチャに書き込まれた色は、フレームが進んでも保存されるため、シェーダー内の値を色としてレンダーテクスチャに書き込み/読み込みすることで、値の保存が可能になります。
一つ注意点として、Cameraコンポーネントはローカルです。
しかし、デフォルトでコンポーネントをdisableにしておき、Animationなどでactiveにすることでワールドとして同期できます。
この場合、Cameraはフレンドにしか見えません。つまり、カメラループはフレンドにしか機能しません。
実際にこの機構を使用する際は、以下の様に使います
- レンダーテクスチャへの書き込み:Quadにシェーダーを適応し、色を出力する
- レンダーテクスチャの読み込み:RenderTextureをシェーダー内で参照する
実装2 - 値の読み書きを行うシェーダー
ぱっと思い付く方法としては、float[-inf,inf]とfloat[0,1]との相互変換があります。
これはatan()などを用いることで可能ですが、読み書きを繰り返すと誤差が無視できなくなっていきます。
floatをbit列として解釈する方法があるので、それを使います。
シェーダー内でのfloatの扱いは32bit。先ほどレンダーテクスチャのフォーマットをRGBA8bitに設定したので、32bitを8bitづつ取り出して色に変換します。
今回はfloat3の読み書きのみ考え、32x3bitを8x3(RGB)x4(ピクセル)に書き込むことにします。
ここで、なぜ8bitテクスチャを使うのか、という疑問があると思います。
このコードを書いていた時の私は、Shader関連 - VRChat 技術メモ帳 - VRChat tech notes (fc2.com)を参考にしていました。
記事内に書かれていますが、当時はRGBAFloatを使うとクラッシュ率が増加したそうです。それに恐怖したため、私も8bitテクスチャを使っています。
しかし、私のフレンドにRGBAFloatを用いている方がいて、問題ないと言っていたので、使って良いと思います(未確認)
以下は読み書きを行うための関数群を定義したBuffer.hlslのコードです
float3 unpack(sampler2D _Tex,uint id)
{
int idx = id % SQRTED_SIZE;
int idy = id / SQRTED_SIZE;
float2 uv = (float2(idx, idy)+float2(0.5,0.5)) / float(SQRTED_SIZE);
float2 uv0 = uv + float2(0.25, 0.25) / float(SQRTED_SIZE);
float2 uv1 = uv + float2(0.25, -0.25) / float(SQRTED_SIZE);
float2 uv2 = uv + float2(-0.25, -0.25) / float(SQRTED_SIZE);
float2 uv3 = uv + float2(-0.25, 0.25) / float(SQRTED_SIZE);
uint3 v0 = uint3(tex2Dlod(_Tex, float4(uv0,0,0)).rgb*255.0+0.5)<<0;
uint3 v1 = uint3(tex2Dlod(_Tex, float4(uv1,0,0)).rgb*255.0+0.5)<<8;
uint3 v2 = uint3(tex2Dlod(_Tex, float4(uv2,0,0)).rgb*255.0+0.5)<<16;
uint3 v3 = uint3(tex2Dlod(_Tex, float4(uv3,0,0)).rgb*255.0+0.5)<<24;
uint3 v = v0+v1+v2+v3;
return asfloat(v);
}
bool pack(uint id,float2 uv)
{
int idx = id % SQRTED_SIZE;
int idy = id / SQRTED_SIZE;
float2 uv0 = float2(idx, idy) / float(SQRTED_SIZE);
float2 uv1 = float2(idx+1, idy+1) / float(SQRTED_SIZE);
return all(uv0 <= uv && uv <= uv1);
}
float3 ixPackColor(float3 xyz, uint ix) {
uint3 xyzI = asuint(xyz);
xyzI = (xyzI >> (ix * 8)) % 256;
return (float3(xyzI) + 0.5) / 255.0;
}
float3 packColor(float3 color,float2 uv)
{
float2 muv = fmod(uv,1.0/float(SQRTED_SIZE));
bool yup = muv.y > 0.5/float(SQRTED_SIZE);
bool xup = muv.x > 0.5/float(SQRTED_SIZE);
uint ix = 0;
if(yup&&xup) ix = 0;
else if(!yup&&xup) ix = 1;
else if(!yup&&!xup) ix = 2;
else ix = 3;
return ixPackColor(color,ix);
}
SQRTED_SIZEは値の保存できる最大数についてのSQRT(最大数)であり、定義済みであるとします。
unpackで値の読み込み、packColorで値の書き込みを行います。
unpackでは、IDに対応した値をRenderTexture内の4pxから復元します。
packColorでは、float3を8bit分だけ取り出しています。
以上で値の読み書きができました。後は普通のゲームプログラミングです
実装3 - ゲームロジックの実装
今回、2048をモデルとビューに分けて実装します。
モデルではゲームロジックを書いていきます。これはビューに値をRenderTexture経由で渡すため、Quadに適応するシェーダです。
以下にコードを示します。
2048Header.hlsl
#define SQRTED_SIZE 32
#define ID_CELLS 0
#define ID_FRAME 16
#define ID_PREV_INPUT 17
2048Model.shader
Shader "Custom/2048Model"
{
Properties
{
_Buffer ("Buffer", 2D) = "white" {}
_Input ("Input", Float) = 0
}
SubShader
{
Tags
{
"RenderType"="Opaque"
// できるだけそのままカメラに映すためにOverlayにする
"Queue"="Overlay"
"DisableBatching"="True"
}
LOD 200
ZWrite Off
ZTest Always
Cull Off
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "2048Header.hlsl"
#include "Buffer.hlsl"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
float3 worldPos : TEXCOORD1;
};
sampler2D _Buffer;
float4 _Buffer_ST;
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _Buffer);
o.worldPos = mul(unity_ObjectToWorld, v.vertex).xyz;
// Perspectiveの場合は見えなくする
if(UNITY_MATRIX_P[3][3]!=1)
{
o.vertex = 0;
}
return o;
}
// 0:None
// 1:Up
// 2:Right
// 3:Down
// 4:Left
// 5:Reset
int _Input;
float hash(float2 n) {
return frac(sin(dot(n, float2(12.9898, 4.1414))) * 43758.5453);
}
float2 hash2(float2 st){
st = float2( dot(st,float2(127.1,311.7)),
dot(st,float2(269.5,183.3)) );
return -1.0 + 2.0*frac(sin(st)*43758.5453123);
}
fixed4 frag (v2f IN) : SV_Target
{
int i,j,k,index;
int cells[4][4];
int frame = 0;
int prevInput = 0,input;
float2 uv = IN.uv;
float3 col = tex2D(_Buffer, uv).rgb;
// 初期化処理
if(_Input==5)
{
col = float3(0,0,0);
// 値の保存
for(i=0;i<4;i++)
{
for(j=0;j<4;j++)
{
index = ID_CELLS + i*4+j;
// セルの保存
col = pack(index,uv)?packColor(float3(0,0,0),uv):col;
}
}
// フレームの保存
col = pack(ID_FRAME,uv)?packColor(float3(0,0,0),uv):col;
// ひとつ前の入力の保存
col = pack(ID_PREV_INPUT,uv)?packColor(float3(0,0,0),uv):col;
// 値の書き込み
return float4(col,1);
}
// 値の読み込み
for(i=0;i<4;i++)
{
for(j=0;j<4;j++)
{
index = ID_CELLS + i*4+j;
// セルの読み込み
int val = int(unpack(_Buffer,index).r+0.5);
cells[i][j] = val;
}
}
// フレームの読み込み
frame = int(unpack(_Buffer,ID_FRAME).r+0.5);
// フレームを進める
frame += 1;
// ひとつ前の入力の読み込み
prevInput = int(unpack(_Buffer,ID_PREV_INPUT).r+0.5);
// 何もしない
if(_Input==0)
{
// フレームの保存
col = pack(ID_FRAME,uv)?packColor(float3(frame,0,0),uv):col;
// ひとつ前の入力の保存
col = pack(ID_PREV_INPUT,uv)?packColor(float3(_Input,0,0),uv):col;
// 値の書き込み
return float4(col,1);
}
// ひとつ前の入力と同じ入力は無視
if(_Input==prevInput)
{
// フレームの保存
col = pack(ID_FRAME,uv)?packColor(float3(frame,0,0),uv):col;
return float4(col, 1);
}
// 入力に応じてセルを動かす
// インデックスの定義
static const int inds[2][4] = { {0,1,2,3}, {3,2,1,0} };
// y方向のインデックス
static const int yrule[4] = {1,0,0,0};
// x方向のインデックス
static const int xrule[4] = {0,1,0,0};
// 動く方向
static const int2 dir[4] = { {0,1}, {1,0}, {0,-1}, {-1,0} };
// インプットを扱いやすいように変換
input = _Input-1;
// セルの更新
for(i=0;i<4;i++)
{
for(j=0;j<4;j++)
{
int yind = inds[yrule[input]][i];
int xind = inds[xrule[input]][j];
int val = cells[yind][xind];
// 空のセルは無視
if(val==0)
{
continue;
}
// セルの移動
while(true)
{
int y = yind + dir[input].y;
int x = xind + dir[input].x;
// 範囲外に出たら移動できなかったとして終了
if(y<0 || y>=4 || x<0 || x>=4)
{
break;
}
// 移動先が空のセルなら移動
if(cells[y][x]==0)
{
cells[y][x] = val;
cells[yind][xind] = 0;
yind = y;
xind = x;
}
// 移動先が同じ値のセルなら結合
else if(cells[y][x]==val)
{
cells[y][x] = val*2;
cells[yind][xind] = 0;
break;
}
// 移動先が異なる値のセルなら終了
else
{
break;
}
}
}
}
// 乱数の生成
int seed = 0;
for(i=0;i<4;i++)
{
for(j=0;j<4;j++)
{
seed += cells[i][j];
}
}
// セルを生成(ランダム)
int2 cent = int2(hash2(float2(seed,0))*4);
int val = pow(2,int(hash(float2(0,seed))*2)+1);
bool flag = 0;
for(i=0;i<4;i++)
{
for(j=0;j<4;j++)
{
int2 pos = int2(i,j)+cent;
pos.x = pos.x%uint(4);
pos.y = pos.y%uint(4);
if(cells[pos.y][pos.x]==0)
{
cells[pos.y][pos.x] = val;
flag = 1;
break;
}
}
if(flag)
{
break;
}
}
// 値の保存
for(i=0;i<4;i++)
{
for(j=0;j<4;j++)
{
index = ID_CELLS + i*4+j;
// セルの保存
col = pack(index,uv)?packColor(float3(cells[i][j],0,0),uv):col;
}
}
// フレームの保存
col = pack(ID_FRAME,uv)?packColor(float3(frame,0,0),uv):col;
// ひとつ前の入力の保存
col = pack(ID_PREV_INPUT,uv)?packColor(float3(_Input,0,0),uv):col;
// 値の書き込み
return float4(col,1);
}
ENDCG
}
}
}
実装の詳細はコメントを参照してください。
ユーザーからの入力の受け取り方は後で説明します。
実装4 - Viewの実装
Font.hlsl
// Require : _FontTex
// : _FontColor
float4 char(int id,float2 uv)
{
float2 nuv = float2(id%uint(16),15-id/uint(16))/16.0 + frac(uv)/16.0;
return tex2D(_FontTex,nuv);
}
float4 number(int id,float2 uv)
{
return char(id+48,uv);
}
float4 alphabet(int id,float2 uv)
{
return char(id+65,uv);
}
bool inuv(float2 uv,float2 pos)
{
float eps = 0.1;
return (uv.x > pos.x+eps && uv.x < pos.x+1-eps && uv.y > pos.y+eps && uv.y < pos.y+1-eps);
}
// アルファベット
#define A(_x,_y,_c,_col,_uv) _col=inuv(_uv,float2(_x,_y))?alphabet(_c,_uv).r*_FontColor:_col
// 数字
#define N(_x,_y,_c,_col,_uv) _col=inuv(_uv,float2(_x,_y))?number(_c,_uv).r*_FontColor:_col
// その他の文字
#define C(_x,_y,_c,_col,_uv) _col=inuv(_uv,float2(_x,_y))?char(_c,_uv).r*_FontColor:_col
2048View.shader
Shader "Custom/2048View"
{
Properties
{
_FontTex ("Font (RGB)", 2D) = "white" {}
_Buffer ("Buffer (RGB)", 2D) = "white" {}
_BackColor ("Background Color", Color) = (0,0,0,0)
_EdgeColor ("Edge Color", Color) = (1,1,1,0)
_FontColor ("Font Color", Color) = (1,1,1,0)
_Glossiness ("Smoothness", Range(0,1)) = 0.5
_Metallic ("Metallic", Range(0,1)) = 0.0
_Alpha ("Alpha", Range(0,1)) = 1.0
}
SubShader
{
Tags
{
"RenderType"="Transparent"
"Queue"="Transparent"
}
LOD 200
Blend SrcAlpha OneMinusSrcAlpha
Cull Off
CGPROGRAM
#pragma surface surf Standard fullforwardshadows alpha:fade
#pragma target 3.0
sampler2D _FontTex;
sampler2D _Buffer;
#include "2048Header.hlsl"
#include "Buffer.hlsl"
#include "Font.hlsl"
struct Input
{
float2 uv_FontTex;
};
half _Glossiness;
half _Metallic;
float _Alpha;
float3 _BackColor;
float3 _EdgeColor;
float3 _FontColor;
void surf (Input IN, inout SurfaceOutputStandard o)
{
float2 uv = IN.uv_FontTex;
float3 col = _BackColor;
float2 muv = frac(uv * 4);
int2 iuv = int2(uv * 4);
int i,j,k,cell,index;
// edge
if (muv.x < 0.02 || muv.x > 0.98 || muv.y < 0.02 || muv.y > 0.98)
{
col = _EdgeColor;
}
// fontの描画
[unroll]
for(index=0;index<16;index++)
{
i=index/4;
j=index%4;
if(!(iuv.x == j && iuv.y == i))
{
continue;
}
// 値の読み込み
cell = ID_CELLS + i*4+j;
// セルの読み込み
int val = int(unpack(_Buffer,cell).r+0.5);
// 空白セルはスキップ
if(val == 0)
{
continue;
}
// 値の桁数の計算
int tmp = val;
int dec = 0;
[loop]
while(1)
{
if(tmp == 0)
{
break;
}
tmp /= uint(10);
dec++;
}
// 桁数に応じて位置を調整しながらフォントを描画
float2 fuv = muv*dec;
fuv.y -= (dec-1)*0.5;
tmp = val;
[loop]
for(k=0;k<dec;k++)
{
int digit = tmp % uint(10);
tmp /= uint(10);
N(dec-k-1,0,digit,col,fuv);
}
}
// Standerdシェーダーのパラメータを設定
o.Albedo = col;
o.Metallic = _Metallic;
o.Smoothness = _Glossiness;
o.Alpha = _Alpha;
}
ENDCG
}
FallBack "Diffuse"
}
実装の詳細はコメントを参照してください。
Font.hlslは文字を描くライブラリです。
2048View.shaderはスタンダードシェーダーを改変したものになっています。
実装5 - 組み立て
2048Model.shaderからマテリアルを作成します。
Bufferには先ほど作成したRenderTextureを設定してください。
このマテリアルをカメラループのクアッドに設定します。
2048View.shaderからマテリアルを作成します
Fontテクスチャはここ からダウンロードしてください。(ShaderToyより)
これをFontに設定し、BufferにはRenderTextureを設定します。
これをQuadに適応します。
これで2048が完成しました。
動作確認
2048ModelマテリアルのInputプロパティをいじることで、動作確認ができます。
値と操作の対応は以下の通りです。
- 0:None
- 1:Up
- 2:Right
- 3:Down
- 4:Left
- 5:Reset
UIの実装、見た目の改善
見た目はこの様に改善しました。
矢印で4方向の入力をとり、リセットボタンも用意しました。
サウンドも入れています(Animationの設定必須)
また、触れるようにするためContactReceiverを触るところにつけています。
右上にあえてRenderTextureを表示しています。ちかちかしていてかわいいです。
Animationの設定
まずはContactReceiverから発火させて2048Modelシェーダーに入力を伝えるためのAnimationを作ります。
Inputプロパティを1~5に切り替えるアニメーションは一瞬にしておきます。
またサウンドを鳴らすため、サウンド(PlayOnAwakeを設定)のついたオブジェクトを非アクティブにします。
Inputプロパティを0に切り替えるアニメーションはサウンドのオブジェクトをアクティブにします。
Animationは以下の画像の様にご自身のアバターに設定してください。
入力待機状態ではInputプロパティを0に切り替えるアニメーションを常に再生しておき、1~5に一瞬だけ切り替えます。ここでサウンドも再生されます。
注意点として、アニメーションの遷移は一瞬で切り替わるようにしないといけません。以下の様に設定してください。
以上で、入力に対するアニメーションの設定は終了です。
実際に使用する際はCameraコンポーネントをデフォルトでdisableにしておき、使うときだけアクティブにしてください。(理由は実装1 - 値の保存を行う機構を参照)
まとめ
作った感想としては、デバッグが面倒!(でも、保存してる状態がそのまま色として見えちゃうの、かわいいと思います)
不備や質問があればTwitterのDMかこの記事のコメントでお書きください。
明日のアドベントカレンダーは@inutamago_dogeggと@kegraが担当します。お楽しみに!