背景
どうも今川です。
ゲーム制作をしていてしばしば、ボタンやフレーム、カーソルといったもので、角丸四角形の図形をuGUIで描画することがあると思います。
これを簡単に実装しようと思った時、Image と 角丸画像 (Sprite) をいくつか用意して作成することが多いと思いますが、枠線をつける際に太さに合わせた画像を作り直したり、拡大して利用しようとするとエイリアシングが気になったりなど問題が発生します。
これらは、角丸画像 (Sprite)から、角丸四角形を描画する事が原因であり、逆に角丸画像 (Sprite)から描画しなければこれらの問題は発生しないわけです。
Mask対応に関しては こちら に記載しております。あわせてどうぞ。 - 追記 (2023/11/27)
利用する技術
- Graphic
- ShaderGraph
Graphic
Sprite を利用しないため、Imageによる描画は行えません。
そのため、 Graphic を用いて CanvasRenderer で描画を行います。
ShaderGraph
Graphic のレンダリングに使用される Material の Shader は独自で作成したものを利用します。
そのため、簡単にShaderを作成するため ShaderGraph を利用しました。
前提知識
Spriteを用いらずに図形を描画するために必要な知識として大きく2つあります。
- メッシュの描画
- シェーダーによる図形の描画
メッシュの描画
独自に uGUI で Canvas にメッシュを描画するには、 OnPopulateMesh にて VertexHelper にメッシュを指定することで実現できます。
シェーダーによる図形の描画
メッシュの描画だけでは、ただの四角形が描画されるだけです。角丸の表現や枠線など描画に装飾を行う場合、メッシュにカスタマイズされたシェーダーによるマテリアルを付与して描画する必要があります。
この際、メッシュに描画する際に利用する座標系(UV座標)を指定してあげる必要があるのですが、こちらも VertexHelper にメッシュを設定する際に指定することが可能です。
実現方法
それでは Sprite 無しで角丸四角形の図形を uGUI で描画していきます。
角丸の図形を描画するための手順を以下に記載します。
- 9スライスのメッシュを作成
- 9スライスのメッシュを角丸の半径に合わせてサイズと座標を調整
- 9スライスのメッシュに描画したい角丸四角形の図形をShaderを用いて指定
9スライスのメッシュを作成
まず角丸四角形の図形を描画する際に必要なメッシュの形状は9スライスのメッシュです。9スライスのメッシュにしない場合、図形全体の収縮に合わせて角丸の形状も変化してしまいます。
よってコーナーのサイズは変化しない9スライスのメッシュ作成する必要があります。
9スライスのメッシュを作成するのに必要な最小の情報は、9分割されたメッシュの対角線上の4つの座標です。
これら、の情報を元に9つの四角形のメッシュを作成します。
9スライスのメッシュを角丸の半径に合わせてサイズと座標を調整
次に角丸の半径に合わせてコーナーのサイズを求めサイズと座標を調整します。
Graphic の RectTransform の Rect (以下 「GraphicRect」 という) に四角形のサイズを合わせるとき、
コーナーのサイズは 角丸の半径
コーナーの座標は、以下の式で求められます。
- コーナー左下のメッシュの座標
- 左下 : GraphicRectの座標
- 右上 : GraphicRectの座標 + コーナーのサイズ
- コーナー右上のメッシュの座標
- 左下 : GraphicRectの座標 + GraphicRectの幅 - コーナーのサイズ
- 右上 : GraphicRectの座標 + GraphicRectの幅
この時に枠線を描画することを考慮するのであれば、それらも含めて位置とサイズを調整する必要があります。
枠線について考えてみましょう。
枠線の描画位置は、AdobeやFigmaなどで以下のように定義されていることが多いです。
- Inside : 内側
- Center : 中央
- Outside : 外側
これらは、0~1の値で表現することが可能です。(0: Outside, 0.5: Center, 1: Inside)
また、枠線と角丸の半径、コーナーのサイズと座標の関係性も枠線の描画位置によって変わります。
コーナーのサイズ
まずは、コーナーのサイズに関して考えてみましょう。
枠線の描画位置によるコーナーのサイズを Outside / Inside に関して考えると以下のようになります。
Outside の場合
枠線の太さに関係なく常にコーナーのサイズは 角丸の半径 + 枠線の太さ になります。
Inside の場合
枠線の太さと角丸の半径によって、コーナーのサイズが変わります。
- 角丸の半径 > 枠線の太さ : コーナーのサイズは 角丸の半径
- 角丸の半径 <= 枠線の太さ : コーナーのサイズは 枠線の太さ
以上のことから、コーナーのサイズは以下の式で求められます。
- 角丸の半径 > 枠線の太さ * 枠線の描画位置 : コーナーのサイズは 角丸の半径 + 枠線の太さ * (1 - 枠線の描画位置)
- 角丸の半径 <= 枠線の太さ * 枠線の描画位置 : コーナーのサイズは 枠線の太さ
コーナーの座標
次に、コーナーの座標に関して考えます。こちらも同じく Outside / Inside に関して考えると以下のようになります。
Inside の場合
枠線の太さに関係なくコーナーの位置は、角丸四角形と同じ座標になります。
Outside の場合
枠線分、角丸四角形からはみ出るためコーナーの座標は全体的に角丸四角形の中心から外側に向かって 枠線の太さ 分離れた座標になります。
以上のことから、コーナーの座標は以下の式で求められます。
- コーナー左下のメッシュの座標
- 左下 : (GraphicRectの座標 - 枠線の太さ * (1 - 枠線の描画位置)
- 右上 : GraphicRectの座標 + コーナーのサイズ - 枠線の太さ * (1 - 枠線の描画位置)
- コーナー右上のメッシュの座標
- 左下 : GraphicRectの座標 + GraphicRectの幅 - コーナーのサイズ + 枠線の太さ * (1 - 枠線の描画位置)
- 右上 : GraphicRectの座標 + GraphicRectの幅 + 枠線の太さ * (1 - 枠線の描画位置)
以上で、コーナーのサイズと座標が求められました。
これで、角丸の半径と枠線に合ったメッシュを生成する事ができます。
protected override void OnPopulateMesh(VertexHelper toFill)
{
var boarder = radius > thickness * linePosition ? radius + thickness * (1 - linePosition) : thickness;
var rect = GetPixelAdjustedRect();
// 9Slice 左下の四角形の左下の座標
VertScratch[0] = new Vector2(
rect.x - thickness * (1 - linePosition),
rect.y - thickness * (1 - linePosition)
);
// 9Slice 左下の四角形の右上の座標
VertScratch[1] = new Vector2(
boarder + rect.x - thickness * (1 - linePosition),
boarder + rect.y - thickness * (1 - linePosition)
);
// 9Slice 右上の四角形の左下の座標
VertScratch[2] = new Vector2(
rect.width - boarder + rect.x + thickness * (1 - linePosition),
rect.height - boarder + rect.y + thickness * (1 - linePosition)
);
// 9Slice 右上の四角形の右上の座標
VertScratch[3] = new Vector2(
rect.width + rect.x + thickness * (1 - linePosition),
rect.height + rect.y + thickness * (1 - linePosition)
);
// UV座標左下
UVScratch[0] = new Vector2(0, 0);
// UV座標左下から上右に1/4
UVScratch[1] = new Vector2(0.25f, 0.25f);
// UV座標右上から下左に1/4
UVScratch[2] = new Vector2(0.75f, 0.75f);
// UV座標右上
UVScratch[3] = new Vector2(1, 1);
toFill.Clear();
for (var x = 0; x < 3; ++x)
{
var x2 = x + 1;
for (var y = 0; y < 3; ++y)
{
var y2 = y + 1;
var posMin = new Vector2(VertScratch[x].x, VertScratch[y].y);
var posMax = new Vector2(VertScratch[x2].x, VertScratch[y2].y);
AddQuad(
toFill,
posMin,
posMax,
Color.white,
new Vector2(UVScratch[x].x, UVScratch[y].y),
new Vector2(UVScratch[x2].x, UVScratch[y2].y));
}
}
}
9スライスのメッシュに描画したい角丸四角形の図形をShaderを用いて指定
これで、メッシュの準備は整いました。最後に9スライスのメッシュに角丸の図形を描画していきましょう。
まずは、UVについておさらいしましょう。
UV座標とは簡単に言うと、メッシュの面ごとに貼り付けるテクスチャ上の座標を0~1で表したものです(0 : 左下, 1 : 右上)。
メッシュの座標が9スライスだから、UVも上下左右に3等分で考えたいのですが、何かと計算に都合が悪いので2の累乗である4等分にして考えてみます。
UV上では 4x4 の16スライスの画像をメッシュの9スライスにそれぞれUV座標を当てはめると以下のように考える事ができます。
- コーナー左下のメッシュのUV座標
- 左下 : (0, 0)
- 右上 : (0.25, 0.25)
- コーナー右上のメッシュ
- 左下 : (0.75, 0.75)
- 右上 : (1, 1)
これで、メッシュ座標とメッシュのUV座標の対応が完了しました。
次にメッシュに描画するテクスチャーの見た目をシェーダーで調整していきます。
枠線無しのテクスチャ
まずは枠線がない場合を考えます。
角丸を描画するには、コーナーが角丸それ以外は塗りつぶされているテクスチャを用意すればいいわけです。角丸のサイズは9スライスのメッシュがよしなに調整してくれるため考慮する必要がありません。
角丸の四角形を ShaderGraph で作成する方法は Rounded Rectangle を利用すれば作成可能です。
テクスチャのサイズはUVで考えるため、コーナーのサイズは全体の 1/4 のため
- Radius : 0.25
- Width / Height : 1
になるわけです。
これで、枠線なしの角丸の描画が完了しました。
枠線ありのテクスチャ
次に、枠線を含めた描画を考えていきます。
こちらも、メッシュの座標とサイズと同様に枠線と角丸の半径、枠線の位置によってテクスチャが変わります。
まず、角丸四角形と枠線のテクスチャを Outside / Inside に関して考えると以下のようになります。
Outside の場合
角丸四角形のテクスチャは、コーナーのサイズが 角丸の半径 + 枠線の太さ のことから
- Radius : 角丸の半径 / (角丸の半径 + 枠線の太さ) * 0.25
- Width / Height : 角丸の半径 / (角丸の半径 + 枠線の太さ) * 0.5 + 0.5
枠線のテクスチャは、枠線がない時のテクスチャから角丸四角形のテクスチャを引いたテクスチャになります。
枠線のマテリアルプロパティは以下になります。
- Thickness (Float) : 枠線の太さ
- Radius (Float) : 角丸の半径
- LinePosition (Float) : 枠線の位置 (0: Outside, 0.5: Center, 1: Inside)
枠線の色を変えてみましょう。
マテリアルプロパティに以下を追加します。
- StrokeColor (Color) : 枠線の色
- FillColor (Color) : 角丸四角形の色
枠線と角丸四角形それぞれのアルファ値を各色に合成します。
ここで注意が必要なのですが、加算や乗算による合成では、期待している結果になりません。
いわゆる画像の上に半透明の画像を重ねたような合成は、画像A(上に重ねる画像) 画像B(下の画像) の時、以下の計算で求めます。
- 合成後の透明度 : Out.a = A.a + B.a * (1 - A.a);
- 合成後のRBG : Out.rgb = (A.xyz * A.a + B.rgb * B.a * (1-A.a)) / Out.a;
こちらの Node は繰り返し使うので SubGraph として登録するのがいいでしょう。
ただ、これではまだ未完成です。
マテリアルプロパティとメッシュの座標とサイズを求めるときに利用した
- 枠線の太さ
- 角丸の半径
- 枠線の位置
の値が同期していません。
マテリアルプロパティの更新には、 IMaterialModifier.GetModifiedMaterial を利用します。
GetModifiedMaterial で受け取ったマテリアルをコピーしたマテリアルを生成し、変更を加えたい値のみ値のみ更新していきます。
private Material _material;
Material IMaterialModifier.GetModifiedMaterial(Material baseMaterial)
{
if (_material == null)
{
_material = new Material(baseMaterial)
{
hideFlags = HideFlags.DontSave
};
_material.CopyMatchingPropertiesFromMaterial(baseMaterial);
}
_material.SetThickness(thickness);
_material.SetRadius(radius);
_material.SetLinePosition(linePosition);
return _material;
}
protected override void OnDestroy()
{
base.OnDestroy();
var m = _material;
if (m == null) return;
_material = null;
if (Application.isPlaying)
{
Destroy(m);
}
else
{
DestroyImmediate(m);
}
}
---
internal static class MaterialExtensions
{
private static readonly int Thickness = Shader.PropertyToID($"_{nameof(Thickness)}");
private static readonly int Radius = Shader.PropertyToID($"_{nameof(Radius)}");
private static readonly int LinePosition = Shader.PropertyToID($"_{nameof(LinePosition)}");
public static void SetThickness(this Material material, float value)
{
material.SetFloat(Thickness, value);
}
public static void SetRadius(this Material material, float value)
{
material.SetFloat(Radius, value);
}
public static void SetLinePosition(this Material material, float value)
{
material.SetFloat(LinePosition, value);
}
}
これで Outside の枠線の描画は完成です。
Inside の場合
角丸四角形のテクスチャは、コーナのサイズが以下であることから
- 角丸の半径 > 枠線の太さ : コーナーのサイズは 角丸の半径
- 角丸の半径 <= 枠線の太さ : コーナーのサイズは 枠線の太さ
角丸の半径 > 枠線の太さ の時、枠線がない時と同じテクスチャになります。
- Radius : 0.25
- Width / Height : 1
角丸の半径 <= 枠線の太さ の時、 角丸の半径 / 枠線の太さ 分コーナーが小さくなります。
- Radius : 角丸の半径 / 枠線の太さ * 0.25
- Width / Height : 1
枠線のテクスチャは、角丸四角形のテクスチャから枠線分内側に小さいテクスチャを引いたテクスチャになります。
角丸の半径 > 枠線の太さ の時、枠線分内側に小さいテクスチャは
- Radius : (角丸の半径 - 枠線の太さ) / 角丸の半径 * 0.25
- Width / Height : (角丸の半径 - 枠線の太さ) / 角丸の半径 * 0.5 + 0.5
角丸の半径 <= 枠線の太さ の時、 枠線分内側に小さいテクスチャは中央いっぱいに塗りつぶされているため、角丸がない中央の四角形になります。
- Radius : 0
- Width / Height : 0.5
これを Outside と同様に枠の色をつけることで完成します。
以上のことから、テクスチャは以下の式で求められます。
角丸の半径 > 枠線の太さ * 枠線の位置 の時、
枠線のテクスチャは
- Radius : 0.25
- Width / Height : 1
の角丸四角形から
- Radius : (角丸の半径 - 枠線の太さ * 枠線の位置) / (角丸の半径 + 枠線の太さ * (1 - 枠線の位置)) * 0.25
- Width / Height : (角丸の半径 - 枠線の太さ * 枠線の位置) / (角丸の半径 + 枠線の太さ * (1 - 枠線の位置)) * 0.5 + 0.5
の角丸四角形を引いたものになります
角丸の半径 <= 枠線の太さ * 枠線の位置 の時、
- Radius : (角丸の半径 + 枠線の太さ * (1 - 枠線の位置)) / 枠線の太さ * 0.25
- Width / Height : 1
の角丸四角形から
- Radius : 0
- Width / Height : 0.5
の正方形を引いたものになります
角丸四角形のテクスチャは、境界線を求める時に利用した外側の四角形Aと内側の四角形Bの間であるため
- Radius : Lerp(B.Radius, A.Radius, 枠線の位置)
- Width / Height : Lerp(B.(Width / Height), A.(Width / Height), 枠線の位置)
で求めることができます。
結論
以下のコンポーネントと Shader を利用することで、Sprite 無しで枠つきの角丸四角形の図形を描画する事ができます。
Code
Shader
あとは、拡張として枠線を光らせたり、中の塗りを消したり、擬似的にカラーを重ねたりなど、ShaderGraphなので簡単に機能拡張が可能です。