Unityでカードのスクラッチ機能を作ってみた
サイバーエージェントのゲーム・エンターテイメント事業部(SGE)に所属する子会社QualiArtsでUnityエンジニアをしている飯田です。本記事はQualiArtsの定期ブログ「QualiArts Tech Note」第7弾の記事となります。QualiArtsでは会社で使われている様々な技術の知見をブログで紹介しています。興味のある方は他の記事もチェックしてみてください。
はじめに
スクラッチ機能とは、カードイラストが表示されている状態でユーザーがイラスト部分をスクラッチのように指でなぞると、なぞった部分だけ下から別の絵が出てくるというものです。QualiArtsが運用している「オルタナティブガールズ2」では「バースト衣装」という名前でリリースされています。
今回はこの機能を作った時のことを技術的な視点で紹介していきたいと思います。
スクラッチ機能の仕組みについて
今回このスクラッチ機能をUnityで実装するにあたり、処理の流れの大枠は以下のようになっています。
- ユーザーのインプット(タップの軌跡)を受け取る
- 入力されたpositionを元に、パターンテクスチャ(※後述)を作成する
- 作成したパターンテクスチャを元に、画像Aと画像Bをブレンドし、最終アウトプットとする
この流れを一つずつ、以下の項で細かく紹介していきます。
上記で登場したパターンテクスチャとは、画像を絵としての本来の使い方ではなく、RGBAの各チャンネルに値0.0~1.0をもったデータの集合体として使用するというものです。今回の仕組みでは、インプットとして受け取ったpositionを元に塗り絵のようになぞった部分に値を入力していきます。なぞった軌跡部分を値1.0、触れられてない部分を値0.0としてパターンテクスチャ化し、そのパターンをもとに最終的に画像をブレンドしていきます。
ユーザーのインプットを受け取る
UnityではInput Systemが新旧の2通りあります。
ここではざっくりとした紹介になります が、旧式のInput Systemではフレーム毎の入力だけしか受け取ることができなかったのですが、新しいInput Systemでは各フレーム内で複数のイベントを受け取ることができるようになっています。各フレーム内で複数のイベントが受け取れることのメリットとして、FPSが低い状態で指を高速に動かした時でも指の動きを追うことができるということがあります。
今回紹介するスクラッチ機能においては新式でなくてはいけないということはないのですが、新式のInput Systemを使った方がより正しく指の動きを追うことができるのでオススメです。
Input Systemを通して受け取ったpositionは画面の左下なら(0, 0)、画面の右上なら(x, y)のようになっていると思います。(右上のx, yは端末によるのですが、ここではx:640, y:1136として話を進めて行こうと思います。)しかし、640や1136という数値はそのままでは使うことはできず、それをViewport座標に変換してあげる必要があります。これはパターンテクスチャを作成する際にuv値として使用するためであり、左下を(0.0, 0.0)、右上を(1.0, 1.0)とした座標に変換してあげるというものです。
変換する方法として一番最初に思いつくのは入力された値を画面サイズで割ることですが、入力として受け取ったpositionをScreen.widthやheightで割っただけだと問題が生じることがあります。これはカードイラストを全画面表示している際に、イラストの縦横比率と端末の縦横比率が合っておらずにイラストの上下左右に余白部分が発生する場合に発生してしまいます。これを防ぐために、インプットのpositionは余白部分を考慮して補正してあげる必要があります。もしもイラストと端末の余白 部分を何も考慮せずに、インプットの座標をそのままViewport座標に変換してしまったらこうなるというものを可視化したのがこちらです。
画面の中央近くでは比較的場所が合っているような気もしますが、上下端に近づくほど無視できないほどズレているのがわかります。そこで、Screenのwidth, heightだけでなくイラスト画像のwidth, heightも使ってuv値を算出することでこの差を補正してあげます。
var illustWidth = 640;
var illustHeight = 960;
var uv = new Vector2(inputPosition.x / Screen.width, inputPosition.y / Screen.height);
// 画像サイズに対して画面上下に余白がある場合、余白分を考慮する必要がある
uv.x = 0.5f + (uv.x - 0.5f) * ((float) Screen.width / illustWidth);
uv.y = 0.5f + (uv.y - 0.5f) * ((float) Screen.height / illustHeight);
この補正をしてあげたものを先ほどと同様に可視化するとこうなります。
うまく補正でき、無事意図通りの動きになりました。
しかし、これで良しというわけではなく、もう一手間加える必要があります。
その理由がこれです。
動きを激しくした時に、インプットとして得られるpositionの間が開いてしまい、塗りきれていない箇所ができていることがわかります。これを防ぐために、入力として受け取ったインプットを最後の1回分保持しておき、 今回のインプットとの距離に応じて、その間を補完してあげることで対応することができます。今回は最後のインプットと今回のインプットの間にどれだけの距離が開いても平気なように、このように一定の距離ごとに塗れるように対策しました。
var loopCount = Mathf.FloorToInt(Vector2.Distance (inputPosition, lastPosition) / 10f);
for (var i = 0; i < loopCount; i++) {
var tempPosition = Vector2.Lerp(lastPosition, inputPosition, 1f / loopCount * i);
// ここで塗りの作業
}
負荷的にはこの距離の幅を広くしてあげたほうがもちろんいいのですが、アウトプットの質の高さも大事なのでこの値としています。このようにして得られた値を、パターンテクスチャを作成する際にuv値として使用していきます。
パターンテクスチャを作成する
前述の通り、パターンテクスチャはRGBAの各チャンネルに値0.0~1.0をもったデータの集合体として使用することができるのですが、各チャンネルごとに何の値として使うかを実装者が決めることができます。最終的なアウトプットを考慮してRGBの各チャンネルについて今回は以下のように定義しました。
- Rチャンネル:画像Aと画像Bのブレンド比率(0だと画像A、1だと画像Bを描画)
- Gチャンネル:画像A/Bをブレンドする際の境界部分定義、Gの値に応じて白くする
- Bチャンネル:不使用
実際の手順としては、パターンテク スチャ用のTextureを用意し、Graphics.BlitをつかってTextureに対して塗り素材画像をコピーしていきます。Graphics.Blitは、source textureとdest textureを引数にとり、source textureをdest textureにコピーするものですが、materialを引数に取ることが出来ます。この引数のmaterialをうまく使うことで、パターンテクスチャを作っていこうというワケです。
このmaterialに使用するshaderへの入力はこのようになります。
_MainTex ("Texture", 2D) = "white" {}
_SrcTex("SrcTex", 2D) = "black" {}
_SrcUv("SrcUV", vector) = (0,0,0,0)
_SrcScale("SrcScale", float) = 0.13
_MainTex
はパターンテクスチャとなります。
_SrcTex
は塗るための素材画像(以下、塗り素材画像)で、ここでは以下のようなものを使用しています。
_SrcUv
は塗り素材画像を描画する座標を表しています。
_SrcScale
はカードの大きさに対する塗り素材画像の大きさの比率。
Graphics.Blitを使いsource textureからdest textureに画像をコピーする際に、materialに設定された座標、大きさに塗り用画像を合成してdest textureにしようということです。
ここでいくつか補足があります。
今回用意したこの塗り素材画像、なぜ正方形じゃないの?と疑問になると思いますが、あえてこのようにしています。もっと具体的にいうと、この画像のサイズは最終的なイラストの縦横比と等しくなるようにしています。画像処理をuv値を用いて行う上で、今回のように縦長のイラストを使う際に塗り素材画像が正方形だと縦に伸びてしまいます。それを防ぐために同じ縦横比の画像にしています。
※比率があっていればサイズが同じである必要はないです、また正方形であってもuv値をさらに調整すれば問題ないです。今回はわかりやすいようにイラストと同比率の縦長画像を用意しました。
Scaleに関しても、uv値で処理を行うためにScaleの情報がないとどでかくドン!と画像が配置されるだけになってしまうので、その対応でScaleも入力として取っています。今回は一定のScaleで行なっていますが、Unityでは 一部の端末で3D Touchなどによるタップ圧を取得することが出来るので、その圧に応じてScaleを変えるなどしても面白いです。
実際のshaderの処理はこのようになっています。
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
fixed4 srcFrag (v2f i, fixed4 mainCol) {
// 塗り画像の色を取得
float2 uv = ((i.uv - _SrcUv.xy) / _SrcScale) + 0.5;
fixed4 srcCol = tex2D(_SrcTex, uv);
// 元画像にr成分が含まれていればgの値は0にする(rの上にgを乗せないようにする)
srcCol.g *= step(mainCol.r, 0.0);
// src画像にrg色成分が少しでも含まれていればrgの強さは1とする(src画像の質を問わないように)
srcCol.rg = step(0.1, srcCol.rg);
// srcのrgb どれかに値が入ってれば0になる
fixed srcAlpha = step(0.1, srcCol.r + srcCol.g + srcCol.b);
// 元画像とsrc画像を合成
fixed4 finalCol = mainCol;
finalCol.rgb = finalCol.rgb * (1 - srcAlpha) + srcCol.rgb * (srcAlpha);
return finalCol;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 col = tex2D(_MainTex, i.uv);
// 対象となる矩形領域の左下と右上を計算
float2 srcLeftBottom = float2(-_SrcScale, -_SrcScale) * 0.5 + _SrcUv.xy;
float2 srcRightTop = float2(_SrcScale, _SrcScale) * 0.5 + _SrcUv.xy;
// 対象領域に含まれているかどうか
float flag = step(srcLeftBottom.x, i.uv.x) * step(i.uv.x, srcRightTop.x) * step(srcLeftBottom.y, i.uv.y) * step(i.uv.y, srcRightTop.y);
// 領域に含まれていればsrc画像を合成、そうでなければそのまま元の色を返す
return flag == 1 ? srcFrag(i, col) : col;
}
このshaderではstep(x,y)という関数を多用していますが、step関数はyがx以上の値であれば1を、そうでなければ0を返すという関数になっています。それを利用して、step() * step()の場合は全てを満たしていれば1、何か1個でも満たしていな ければ0になるというand条件のように使用しています。他にもstep() + step()であれば何か一つでも満たしていれば1以上になるということでorの条件としても使えたりします。
今回、SrcTexは回転を考慮しないシンプルなものになっているので、コピーする領域の判定は非常に簡単にできます。uv値とscale値をもとに対象領域の四隅を計算し、そこに含まれている場合のみ画像の合成を行うというものになっています。
ポイントとして、SrcTexをそのまま合成すると、なぞり終えた箇所をなぞり直したときに再度Gの値が入ってしまい表現として不自然になってしまいます。そこで、MainTexにRの値が存在する場合はなぞり終わった箇所ということにし、Gの値がその上から書かれないようにしています。
このshaderをもとにmaterialを設定し、それを用いてGraphics.Blitしたものが以下になります。
このパターンテクスチャを元に、次の項で画像のブレンドを行なっていきます。この項の最後としてこの項で紹介した入力を受け取ってGraphics.Blitをする流れの実装も載せておきます。
private void UpdatePatternTexture (Vector2 inputPosition)
{
// 描画の準備ができていない状態では何 もしない
if (_patternTexture == null || _material == null || _isPressDown == false) {
return;
}
RenderTexture temporaryTexture = RenderTexture.GetTemporary(IllustWidth, IllustHeight);
// 前回タップ位置と今回タップ位置の間の距離の分だけ補完する
if (_previousPosition != null) {
var lastPosition = _previousPosition.Value;
var loopCount = Mathf.FloorToInt(Vector2.Distance (inputPosition, lastPosition) / 10f);
for (var i = 0; i < loopCount; i++) {
MergeSrcTexture (Vector2.Lerp(lastPosition, inputPosition, 1f / loopCount * i), temporaryTexture);
}
}
// 今回の入力位置に塗 る
MergeSrcTexture (inputPosition, temporaryTexture);
// 最終タップ地点として保持しておく(タップ判定がなくなったら解放する)
_previousPosition = inputPosition;
RenderTexture.ReleaseTemporary(temporaryTexture);
}
private void MergeSrcTexture (Vector2 inputPosition, RenderTexture temporaryTexture)
{
var uv = new Vector2(inputPosition.x / Screen.width, inputPosition.y / Screen.height);
// 画像サイズに対して画面上下に余白がある場合、余白分を考慮する必要がある
uv.x = 0.5f + (uv.x - 0.5f) * ((float) Screen.width / IllustWidth);
uv.y = 0.5f + (uv.y - 0.5f) * ((float) Screen.height / IllustHeight);
_material.SetVector (UvPropertyID, uv);
_material.SetFloat (ScalePropertyID, SrcScale);
Graphics.Blit(_patternTexture, temporaryTexture, _material);
Graphics.Blit(temporaryTexture, _patternTexture);
}
画像をブレンドする
前の項の工程を経てパターンテクスチャを作ることができました。
あとはこれを用いて画像をブレンドするだけなのですが、ではどうやってブレンドするかというとここでも新しくshaderを作っていきます。
単純にブレンドするだけであればlerp(A, B, t);などで良いのですが、今回は1番最初に載せた最終アウトプットにあるような境界の白い部分と、境界のさらに境界の陰影部分を表現する必要があり、これのために一手間加えています。このブレンド用shaderの入力と実装はこのようになっています。
_MainTex ("Main Texture", 2D) = "white" {}
_SubTex ("Sub Texture", 2D) = "white" {}
_PatternTex ("Rule Texture", 2D) = "black" {}
_ShadowDelta ("Shadow Delta", float) = 0.0035
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed3 getAroundPatternColor(float2 uv)
{
fixed3 leftTop = tex2D(_PatternTex, float2(uv.x - _ShadowDelta, uv.y + _ShadowDelta)).rgb;
fixed3 top = tex2D(_PatternTex, float2(uv.x, uv.y + _ShadowDelta)).rgb;
fixed3 rightTop = tex2D(_PatternTex, float2(uv.x + _ShadowDelta, uv.y + _ShadowDelta)).rgb;
fixed3 left = tex2D(_PatternTex, float2(uv.x - _ShadowDelta, uv.y)).rgb;
fixed3 right = tex2D(_PatternTex, float2(uv.x + _ShadowDelta, uv.y)).rgb;
fixed3 leftBottom = tex2D(_PatternTex, float2(uv.x - _ShadowDelta, uv.y - _ShadowDelta)).rgb;
fixed3 bottom = tex2D(_PatternTex, float2(uv.x, uv.y - _ShadowDelta)).rgb;
fixed3 rightBottom = tex2D(_PatternTex, float2(uv.x + _ShadowDelta, uv.y - _ShadowDelta)).rgb;
return leftTop + top + rightTop + left + right + leftBottom + bottom + rightBottom;
}
fixed4 frag (v2f i) : SV_Target
{
fixed4 mainTexCol = tex2D(_MainTex, i.uv);
fixed4 subTexCol = tex2D(_SubTex, i.uv);
fixed4 patternTexCol = tex2D(_PatternTex, i.uv);
// 画像をブレンド
fixed4 finalCol = lerp(mainTexCol, subTexCol, patternTexCol.r);
// パターンテクスチャのGの値に応じて境界の白い部分を作る
finalCol.rgb = lerp(finalCol.rgb, 1, patternTexCol.g);
// 以下、境界の境界の陰影部分についての実装
// パターンテクスチャにRの値があれば陰影をつける候補となる
float inShadowFlag = patternTexCol.r;
// パターンテクスチャにRGBの値がなければ陰影をつける候補になる
float outShadowFlag = step(patternTexCol.r + patternTexCol.g + patternTexCol.b, 0.1);
// 陰影候補になっていれば、周辺の色を取得し、周辺にGの値があればその値を陰影の強さとする
fixed shadowPower = (inShadowFlag + outShadowFlag) >= 1.0 ? min(1.0, getAroundPatternColor(i.uv).g / 8) : 0;
// アウトプットを陰影の強さ分暗くする
finalCol.rgb = max(0, finalCol.rgb - shadowPower);
return finalCol;
}
陰影部分についてなかなかややこしく見える実装ですが、パターンテクスチャの境界部分を拡大した画像をもとに解説していきます。
この図に1~5の番号が振ってある地点があります。
それぞれの地点の周囲にGの値がどれだけあるかを今回は陰影の強さにしようとしています。ここでは例として特定の地点から距離2マスの地点でG値の大きさを陰影の強さとしてみます。周囲にGの値がある地点は1〜4の4つであり、5はGの値が周囲にないので対象外になります。
また、地点13から距離2マス地点のGの値と比べて、4は2マス離れた地点のGの値がそれほど大きくありません。よって、13と4を比べた時に4での処理では陰影の強さを弱めてあげると良さそうです。そして、今回陰影を出したいのは最終アウトプットの白い部分の外側、つまりパターンテクスチャでいうところのGの色の外側になります。(R色、もしくは無色の地点)再度図を確認すると、2に関してはGの値の上にある点なので陰影の対象外ということになります。
よって、この図でいうと陰影をつけるのは1,3,4番の点の箇所であり、陰影の強さは1=3>4となります。
これを実際に実 装しようとしたものが、上記のshaderになるというわけです。この図では2マス離れた地点のGの値を見ていましたが、shaderにおいては特定の距離だけ離れたところのGの値が陰影となるようにしており、その特定の距離を_ShadowDeltaと表して実装しています。このshaderのmaterialを画像に設定し、画像A,Bとパターンテクスチャをmaterialに渡してあげると無事に完成となります。
最後に
最後まで読んでいただきありがとうございます。
今回はシンプルな形での実装としましたが、パターンテクスチャGの値をうまく使って紙をびりびりと破いたかのような表現にしても面白いなと振り返っていて思いました。また、R/Gの値を使ったもののBのチャンネルは空いているのでこのチャンネルを使ってさらにもう一工夫もできる余地もありそうです。何か面白い使い方を思いついたらまた実装してみます。
弊社ではエンジニアからの「こんな機能出来そうなんだけどどうかな?」「こういうの作ってみたんだけど面白そうじゃない?」といった提案で企画が進むことがたびたびあります。本記事で紹介したスクラッチ機能も、実はエンジニアからの 提案で実現した機能でした。面白そうな仕組みを考え、それを実現させようと試行錯誤し、実際に動いた時の感動はひとしおです。
今回の記事が面白いアイデアを実装する際の助けになれば幸いです。