はじめに
こんにちは。ソーシャルゲーム事業部の中山です。
この記事はカヤックUnityアドベントカレンダー2018の6日目の記事です。
今回はUnityで枠を描くときに使える話です。
画面の中に枠を描きたいというとき、枠ごとに専用の素材を用意してしまうと、ゲームの容量が無駄に大きくなったり、描画の負荷が高くなったり、無駄にメモリを消費したりします。
長方形の枠を描くだけであればUnity標準の機能でなんとかできる*1のですが、形が歪んだ枠を何個も描きたい場合はどうすれば良いでしょうか、というのが今回の話です。
実例

これは東京プリズンというゲームのスクリーンショットです。
画面右に大きな枠が描かれていますが、
もともとこの枠は画像を使って描かれていて、UnityのSceneウィンドウで表示をOverdrawに切り替えると以下のようになっていました。

これだけ見ても何がいけないのかわからないかと思いますが、今回紹介する方法で枠を描くようにすると以下のようになります。

全体的に少し暗くなっているのがわかるかと思います。
Overdrawは描画の負荷が高いところほど表示が明るくなるというものなので、全体的に少し負荷が下がったということになります。
枠の素材は大部分が透明なのですが、uGUIのImageコンポーネントはそんなこと関係なしに素材の全体を描画しようとするので、単純に素材を置いて枠を描いてしまうと大きな無駄が発生することになります*2。
また、枠の画像のサイズは597×676なので、素材をロードするとそれだけでメモリを約1.5MB消費することになります*3。
今回紹介する方法を使えば素材をロードしなくて済むので、この分のメモリの消費を抑えられるということになります。
このゲームのバトル画面は描画しないといけないものが多いので、できるだけ低スペックなマシンでも快適にプレイできるようにするには、このようなパフォーマンスチューニングが重要になってきます。
適当な例

ここではこのような枠を描くことを考えます。
まず描く
メッシュを動的に生成することで、専用の素材なしで枠を描くことができます。
以下のようなコンポーネントを作り、キャンバスの下の適当なGameObjectに追加すれば良いです。
位置の調整を楽にする
ここまでで一応いい感じに枠が描けるようになったのですが、座標を一つ一つ手で入力しないといけないので位置の調整が面倒です。
ハンドルというものをつけるとマウスで調整できて便利なのでやっておきましょう。
ハンドルをつけるとこういう感じで座標を設定できるようになります。

この機能を入れたコンポーネントのソースコードです。
using System;
using UnityEngine;
using UnityEngine.UI;
#if UNITY_EDITOR
using UnityEditor;
#endif
public class WakuGraphic : Graphic
{
[Serializable]
private struct VertexPair
{
public Vector2 outer;
public Vector2 inner;
}
public Sprite sprite;
[SerializeField]
private bool _isClosed = false;
[SerializeField]
private float _antialiasWidth = 1f;
[SerializeField]
private VertexPair[] _vertices;
public override Texture mainTexture
{
get
{
if (sprite != null)
{
return sprite.texture;
}
else
{
return base.mainTexture;
}
}
}
protected override void OnPopulateMesh(VertexHelper vh)
{
vh.Clear();
if (_vertices.Length < 3)
{
return;
}
var uv = new Vector2();
var dstSizeDelta = rectTransform.sizeDelta;
if (sprite != null)
{
float offsetX = 0f;
float offsetY = 0f;
if (sprite.packed)
{
offsetX = sprite.textureRect.x;
offsetY = sprite.textureRect.y;
}
uv.x = (offsetX + sprite.rect.width / 2f) / sprite.texture.width;
uv.y = (offsetY + sprite.rect.height / 2f) / sprite.texture.height;
}
var outerColor = color;
outerColor.a = 0f;
bool clockwize = Cross(_vertices[1].outer - _vertices[0].outer, _vertices[2].outer - _vertices[1].outer) > 0f;
var pos = new Vector3();
for (int i = 0; i < _vertices.Length; ++i)
{
int prevIndex = (i != 0 ? i - 1 : _vertices.Length - 1);
int nextIndex = (i != _vertices.Length - 1 ? i + 1 : 0);
var v = _vertices[i];
var outward = CalcOutwardVector(
clockwize,
ref v.outer,
ref _vertices[prevIndex].outer,
ref _vertices[nextIndex].outer);
pos = v.outer;
pos.x *= dstSizeDelta.x;
pos.y *= dstSizeDelta.y;
outward.x += pos.x;
outward.y += pos.y;
vh.AddVert(pos, color, uv);
vh.AddVert(outward, outerColor, uv);
outward = CalcOutwardVector(
clockwize,
ref v.inner,
ref _vertices[prevIndex].inner,
ref _vertices[nextIndex].inner);
pos = v.inner;
pos.x *= dstSizeDelta.x;
pos.y *= dstSizeDelta.y;
outward.x += pos.x;
outward.y += pos.y;
vh.AddVert(pos, color, uv);
vh.AddVert(outward, outerColor, uv);
}
for (int i = 0; i < _vertices.Length - 1; ++i)
{
vh.AddTriangle(i * 4, i * 4 + 2, (i + 1) * 4);
vh.AddTriangle(i * 4 + 2, (i + 1) * 4, (i + 1) * 4 + 2);
vh.AddTriangle(i * 4, i * 4 + 1, (i + 1) * 4);
vh.AddTriangle(i * 4 + 1, (i + 1) * 4, (i + 1) * 4 + 1);
vh.AddTriangle(i * 4 + 2, i * 4 + 3, (i + 1) * 4 + 2);
vh.AddTriangle(i * 4 + 3, (i + 1) * 4 + 2, (i + 1) * 4 + 3);
}
if (_isClosed)
{
var i = _vertices.Length - 1;
vh.AddTriangle(0, 2, i * 4);
vh.AddTriangle(2, i * 4 + 2, i * 4);
vh.AddTriangle(0, i * 4, i * 4 + 1);
vh.AddTriangle(0, 1, i * 4 + 1);
vh.AddTriangle(2, i * 4 + 2, i * 4 + 3);
vh.AddTriangle(2, 3, i * 4 + 3);
}
}
private Vector2 CalcOutwardVector(bool clockwize, ref Vector2 refVert, ref Vector2 prevVert, ref Vector2 nextVert)
{
var v1 = refVert - prevVert;
var v2 = nextVert - refVert;
v1.Normalize();
v2.Normalize();
var v1_ = v1;
Rotate(ref v1_);
var v2_ = v2;
Rotate(ref v2_);
float a = Mathf.Sqrt(Vector2.SqrMagnitude(v2_ - v1_) / Vector2.SqrMagnitude(v2 + v1));
if (!clockwize)
{
a = -a;
}
return _antialiasWidth * (v1_ - v1 * a);
}
private void Rotate(ref Vector2 v)
{
float a = v.x;
v.x = -v.y;
v.y = a;
}
private float Cross(Vector2 a, Vector2 b)
{
return a.x * b.y - a.y * b.x;
}
#if UNITY_EDITOR
[CustomEditor(typeof(WakuGraphic), true)]
public class WakuGraphicInspector : Editor
{
private void OnSceneGUI()
{
Tools.current = Tool.None;
var component = target as WakuGraphic;
var vertices = component._vertices;
if (vertices != null && vertices.Length >= 2)
{
var rectTransform = component.rectTransform;
var sizeDelta = rectTransform.sizeDelta;
var pivot = rectTransform.pivot;
var mat = rectTransform.localToWorldMatrix;
var inv = rectTransform.worldToLocalMatrix;
for (int i = 0; i < vertices.Length; ++i)
{
var v = vertices[i].outer;
v.x *= sizeDelta.x;
v.y *= sizeDelta.y;
var currentPosition = mat.MultiplyPoint(v);
PositionHandle(ref vertices[i].outer, ref inv, ref currentPosition, ref sizeDelta);
v = vertices[i].inner;
v.x *= sizeDelta.x;
v.y *= sizeDelta.y;
currentPosition = mat.MultiplyPoint(v);
PositionHandle(ref vertices[i].inner, ref inv, ref currentPosition, ref sizeDelta);
}
component.SetVerticesDirty();
}
}
void PositionHandle(ref Vector2 targetPoint, ref Matrix4x4 inverse, ref Vector3 position, ref Vector2 sizeDelta)
{
var handleSize = HandleUtility.GetHandleSize(position) * 0.2f;
var newWorldPosition = Handles.FreeMoveHandle(position, Quaternion.identity, handleSize, new Vector3(1f, 1f, 0f), Handles.CircleHandleCap);
var newPosition = inverse.MultiplyPoint3x4(newWorldPosition);
newPosition.x /= sizeDelta.x;
newPosition.y /= sizeDelta.y;
if (Mathf.Abs(newPosition.x - targetPoint.x) > 1e-5f)
{
targetPoint.x = newPosition.x;
}
if (Mathf.Abs(newPosition.y - targetPoint.y) > 1e-5f)
{
targetPoint.y = newPosition.y;
}
}
}
#endif
}
下の方の WakuGraphicInspector クラスがハンドルをつけるためのクラスで、 PositionHandle がハンドルを置くためのメソッドです。
応用
枠の頂点の座標を自分で計算しているので、画像を用いて枠を作っている場合にはできないようなことができます。
例えば、実行時に枠を変形させられます。

頑張ればこういう演出も作れるでしょう。
初音ミク Project mirai 2 OP曲『アゲアゲアゲイン』フル ver.PV
他には何ができるか考えてみると良いかもしれません。
最後に
明日はながたさんによる「ComputeShaderによるVectorField計算」です。