はじめに
本記事は、QualiArts Advent Calender 2023 の20日目の記事になります。
ゲーム開発において、下のGIFのように円(円周)が波紋のように広がって消えていく演出を作りたくなることがあります。
固定の円を描画するだけなら画像を使えば基本的に十分ですが、円の太さが変化する表現をしたい場合は単純に実現することができません。
本記事では、そのような表現をSDFという技術を使いつつUnityで実現する方法を紹介します。
また、単純にSDFを使うだけだとジャギる(境界がギザギザする)現象があるため、それを解消するアンチエイリアスの方法についても紹介します。
UnityのバージョンはUnity2022.3.14f1を利用しています。
円のSDF
SDFとは、Signed Distance FieldまたはSigned Distance Functionの略で、各点から特定の形状までの最短距離を表す場(または関数)のことを指します。
SDFは「距離」を返す関数として表現されますが、Signedとあるように符号が意味を持ち、正だと図形の外、負だと図形の中、0だとちょうど図形の境界を意味します。
SDFを利用することで、形状の内部と外部を簡単に判別することができ、拡大しても非常になめらかに形状を描画することができます。
円のSDFを式で表すと、posを点の位置、radiusを(厚みの中心までの)半径、thicknessを円の厚みとすると、
$f(pos) = |distance(pos) - radius| - thickness$
となります。
ここで、$distance(pos)$は原点からposへの距離です。
SDFの大きな特徴として、SDFの式さえ作れてしまえばそれを利用する処理は具体的な形状を基本的には意識しなくて良いという点が挙げられます。
この特徴により多様な形状を同じ枠組みで描画できるようになります。
Unity標準のテキスト描画の仕組みであるTextMeshProもSDFを用いてきれいな文字表現を実現しています。
ただし、TextMeshProの場合は式で文字の形状を表現しているのではなく、テクスチャにSDFの情報を焼き込むことで利用しています。
さらに、レイマーチングと呼ばれる手法を用いることで、2次元だけでなく3次元の形状をSDFを利用して描画することもできます。
本記事では円のSDFのみ扱いますが、下記のサイトには多くの形状のSDFの式がまとめられているので是非参考にしてください。
2次元 : https://iquilezles.org/articles/distfunctions2d/
3次元 : https://iquilezles.org/articles/distfunctions/
Unityにおける円の描画
UnityでSDFによる円の描画を行うため、QuadをMeshRendererで描画し、そこに適用するシェーダーの中でSDFを利用します。
説明をできるだけシンプルにするためにこのような方法を採用していますが、例えばUI(uGUI)で利用したい場合はuGUIの枠組みに沿った実装にするなどの調整が適宜必要になることに注意してください。
円を描画するシェーダーの用意
まず、SDFで円を描画するシェーダーを用意します。
プロジェクトビューの右クリックメニューからUnlit Shaderを作成します。
今回は名前をCircleにしました。
Unlitシェーダーはそのままだと透過の描画ができないので、TagsやBlendを調整します。
またプロパティに関してはテクスチャー(_MainTex
)は不要なので削除し、色(_Color
)と円の厚み(_Thickness
)を追加します。
さらに、Unlitシェーダーにはフォグに関する処理が入っていますが今回は不要なので削除します。
現時点でのシェーダー全体は以下のようになります。
Shader "Unlit/Circle"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_Thickness ("Thickness", Float) = 0.1
}
SubShader
{
Tags
{
"RenderType"="Transparent"
"Queue" = "Transparent"
}
Blend SrcAlpha OneMinusSrcAlpha
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
fixed4 _Color;
float _Thickness;
v2f vert(appdata v)
{
// これから
}
fixed4 frag(v2f i) : SV_Target
{
// これから
}
ENDCG
}
}
}
続いて頂点シェーダー(vert)の中身を実装します。
ここは非常にシンプルで以下のようにします。
v2f vert(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
o.vertex
は元々の記述から変えていません。
o.uv
は、UVの情報をフラグメントシェーダーで利用したいためそのまま渡しています。
最後にフラグメントシェーダー(frag)の実装になります。
ここがSDFの描画のメインの処理になります。
UVは、そのままだと下図の通り左下を原点、右上を(1,1)とする座標系になっています。
今回は中心を原点とすると都合が良いため、そのようにずらした座標をposとします。
const float2 pos = i.uv - float2(0.5, 0.5);
ここで i
はフラグメントシェーダーの引数です。
この領域にちょうど収まる円の半径は$0.5-thickness$であることに注意しつつ、先述の円のSDFの式を用いてSDFの値(distance)を求めると、
const float thickness = _Thickness;
const float radius = 0.5 - thickness;
const float distance = abs(length(pos) - radius) - thickness;
となります。
SDFを使った図形の表現として最もシンプルな方法は、distanceが負か0なら図形の内部なのでアルファを1、正なら図形の外側なのでアルファを0とする方法です。
実装すると以下のようになります。
fixed4 color = _Color;
color.a *= distance <= 0 ? 1 : 0;
return color;
プロパティで指定された色(_Color
)をベースとして、distanceの値に応じてアルファを調整(乗算)したものを、フラグメントシェーダーの結果として返しています。
シェーダーを利用した円の描画
それでは今作成したシェーダーを使って実際に画面に描画してみます。
プロジェクトビューでシェーダーのファイルを右クリックし、「Create/Material」でマテリアルを作成します。
さらにヒエラルキービューで右クリックメニューから「3D Object/Quad」でQuadのオブジェクトを生成し、今作成したマテリアルをMeshRendererに設定します。
また、ゲームビューで確認しやすいようにCameraの設定を以下のように黒のSolid Color、Orthographicにしておきます。
するとめでたく下図のように円が描画されることが確認できます。
マテリアルのColorとThicknessを編集することで、色や厚みを変えることができます。
ジャギることの確認
現在の実装ではdistanceの正負を見てアルファに1か0を乗算することで形状を表現していました。
color.a *= distance <= 0 ? 1 : 0;
この実装ではピクセルごとに描画するかしないかの二択になるため、境界部分がジャギる(ギザギザしたように見えてしまう)という問題があります。
ジャギる問題は、特に画面の解像度が低い場合に顕著になります。
ところで、UnityのゲームビューにはScale機能があり、マウスホイールや上部のスライダーを操作することで倍率を変更することができます。
この機能を利用して画面を拡大した結果が以下のとおりです。
円の境界がギザギザしている様子が見て取れます。
またこれと関連して、円が非常に細いときにより厄介なことが起こります。
例えばThicknessを0.001にすると以下のような見た目になります。
円の線が途切れ途切れになっており、かなり見栄えが悪くなってしまっています。
特に本記事冒頭のアニメーションのように円の太さがだんだん小さくなって消えていくような演出を作る際に問題になります。
アンチエイリアスの対応
ジャギる問題に対応するために、アンチエイリアスの仕組みを入れる必要があります。
SDFにおけるアンチエイリアスについては、下記の記事を参考にさせていただいております。
アンチエイリアスの基本的な考え方
まず、ジャギる問題が何故発生するかを考えてみます。
下の画像は、ピクセルと本来の図形を表現したものです。
各ピクセルが赤色の線のセルとして表現されており、数学的な本来の図形の形状が白色で表現されています。
理想としては本来の図形をそのまま描画したいですが、ピクセルは有限のサイズなので、どうにか近似する必要があります。
現在の実装である
color.a *= distance <= 0 ? 1 : 0;
は、ピクセルの代表点(中央と考えます)が図形の中か外かで完全に描画するかしないかを切り替えてしまっています。
例えば今の例だと、セルABCDEFIMPは描画せず、GHJKLNOは描画するといった具合です。
これに対して、最も適切な近似として考えられるのは、セル内での本来の図形が占める割合を求めて、それを透明度とするというものです(ピクセル内の色を積分するイメージ)。
例えばセルMなら0.2くらい、セルGなら0.8くらいの透明度にするという形です。
これがアンチエイリアス(ジャギる問題の解消処理)の基本的な考え方になります。
ただしSDFでは厳密にセル内での本来の図形が占める割合を求めるのは不可能なので、さらなる近似でそれらしき値を求めることになります。
SDFにおけるアンチエイリアス
フラグメントシェーダーで計算している点(pos)がピクセルの中央と考えると、SDFの値(distance)が0のときはちょうどピクセルの中央に境界が現れていると考えられます。
このとき、厳密には図形の形状次第にはなりますが、大体そのピクセル内で図形が占める割合は半分だろうと考えられます。
さらに、ピクセルのサイズをpixelSizeとすると、distanceが-pixelSize/2
のときそのピクセルはちょうど図形ですべて覆われ、pixelSize/2
のときそのピクセルは図形と被らない場合が多いだろうと言えます。
このイメージを図示すると下のようになります。
これを実装に落とし込むために、pixelSize、すなわちdistanceの距離空間におけるピクセルのサイズを直接利用するか、逆にdistanceをピクセル空間での長さに変換する必要があります。
ここでは、後者のほうが実装が単純になるため、距離を変換する方法を採用します。
そのような距離をdistanceInPixelsとすると、アンチエイリアスの実装は、
color.a *= saturate(0.5 - distanceInPixels);
となります。
saturateは、入力が0以下なら0、1以上なら1、それ以外なら入力そのままの値を返す、HLSLのビルトイン関数です。
これで、図形がピクセルと(近似的に)全く被っていなければアルファは0、完全に被っていたらアルファは1、その中間は線形補間されるようになります。
あとはdistanceInPixelsをどう求めるかです。
そこで活用できるのが、fwidthと呼ばれるビルトイン関数です。
fwidthについての解説は、下記のサイトがとても参考になりました。
簡単に言うと、fwidthは引数で与えられた値について、今計算しているピクセルと隣のピクセルとの差の絶対値を返す関数です。
普通のCPUのプログラミングに慣れているととても違和感がありますが、GPUの特別な機能だろうと思っています。
さて、distanceを求めるのに使った座標はposですが、それはUVの値をずらしただけのものでした。
すなわちdistanceはUV空間における長さになっています。
ここで、
fwidth(i.uv.x)
という値を考えると、この値はUVが隣のピクセルでどれくらい変わるか、つまりピクセルあたりのUV空間における長さになると分かります。
そこで、これにdistancePerPixelという名前をつけます。
const float distancePerPixel = fwidth(i.uv.x)
これを用いてdistanceをピクセル空間での長さに変換すると、
const float distanceInPixels = distance / distancePerPixel;
となります。
以上を踏まえたフラグメントシェーダーの全貌を載せると以下のようになります。
fixed4 frag(v2f i) : SV_Target
{
const float2 pos = i.uv - float2(0.5, 0.5);
const float thickness = _Thickness;
const float radius = 0.5 - thickness;
const float distance = abs(length(pos) - radius) - thickness;
fixed4 color = _Color;
// ここからアンチエイリアス処理
const float distancePerPixel = fwidth(i.uv.x);
const float distanceInPixels = distance / distancePerPixel;
color.a *= saturate(0.5 - distanceInPixels);
return color;
}
結果は下図の通りです。
拡大しても図形の境界がなめらかになっており、アンチエイリアスが効いてきれいに描画できていることが分かります。
さらなる改善
ここまでの対応で無事完了かと思いきや、実は今の実装には少し問題があります。
それは、Thicknessを0にしても図形が消えないという問題です。
実際にThicknessを0にしてみると下のような見た目になります。
これでは冒頭のアニメーションのように厚みを0に変化させるアニメーションをしたときに円が消えないことになり、使い勝手が悪い機能になってしまいます。
この問題の原因は、厚み0の円が存在する場所ではdistanceの値が0となるため、アンチエイリアスの対応によってアルファが0より大きくなってしまうピクセルが存在しうることです。
このように、前節のアンチエイリアスの対応はあくまで近似に過ぎず、極端に細い図形などでは思ったような結果にならない場合があります。
この問題に対応するため、円が細い場合には実際に線を細くすることを諦め、アルファを小さくすることで太さを表現することにします。
具体的には、厚みが1ピクセル未満なら厚みに応じて線形にアルファを小さくしていきます。
厚みが1ピクセル未満かどうかは、先程登場したdistancePerPixelを使えば判定できます。
また1ピクセル未満の場合のアルファ値や厚みの補正にもdistancePerPixelが使えます。
const bool isLessThanOnePixel = _Thickness <= distancePerPixel;
const float alpha = isLessThanOnePixel ? _Thickness / distancePerPixel : 1;
const float thickness = isLessThanOnePixel ? distancePerPixel : _Thickness;
以上を踏まえてフラグメントシェーダーを実装すると以下のようになります。
const float2 pos = i.uv - float2(0.5, 0.5);
const float distancePerPixel = fwidth(i.uv.x);
const bool isLessThanOnePixel = _Thickness <= distancePerPixel;
const float alpha = isLessThanOnePixel ? _Thickness / distancePerPixel : 1;
const float thickness = isLessThanOnePixel ? distancePerPixel : _Thickness;
const float radius = 0.5 - thickness;
const float distance = abs(length(pos) - radius) - thickness;
fixed4 color = _Color;
const float distanceInPixels = distance / distancePerPixel;
color.a *= alpha * saturate(0.5 - distanceInPixels);
return color;
これで、例えばThicknessが0.001のとき以下のような見た目になります。
さらに、Thicknessを0にすれば、円は完全に見えなくなります。
最終的なシェーダー
以上の実装を含めた最終的なシェーダーを載せておきます。
Shader "Unlit/Circle"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_Thickness ("Thickness", Float) = 0.1
}
SubShader
{
Tags
{
"RenderType"="Transparent"
"Queue" = "Transparent"
}
Blend SrcAlpha OneMinusSrcAlpha
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
};
fixed4 _Color;
float _Thickness;
v2f vert(appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
return o;
}
fixed4 frag(v2f i) : SV_Target
{
const float2 pos = i.uv - float2(0.5, 0.5);
const float distancePerPixel = fwidth(i.uv.x);
const bool isLessThanOnePixel = _Thickness <= distancePerPixel;
const float alpha = isLessThanOnePixel ? _Thickness / distancePerPixel : 1;
const float thickness = isLessThanOnePixel ? distancePerPixel : _Thickness;
const float radius = 0.5 - thickness;
const float distance = abs(length(pos) - radius) - thickness;
fixed4 color = _Color;
const float distanceInPixels = distance / distancePerPixel;
color.a *= alpha * saturate(0.5 - distanceInPixels);
return color;
}
ENDCG
}
}
}
これで遂に完成です。
おわりに
本記事では、UnityとSDFを使用して、きれいな円を描画する方法を紹介しました。
その中で、図形の境界部分がジャギる現象への対処法について解説しました。
また、さらなる改善として、厚みが1ピクセル未満の場合には実際に線を細くするのではなく、アルファを小さくすることで太さを表現する方法を示しました。
これにより、円の厚みを0に変化させる波紋のようなアニメーションもきれいに表現できるようにしました。
一方で、厚みが1ピクセル未満の場合に行った対応は、図形の形状に依存した対応と言えます。
SDFは式さえ定義できてしまえば図形の形状を意識しなくて良いという特徴を持っていたため、その特徴をある意味潰してしまっている対応になっています。
SDFのアンチエイリアスはあくまで近似であるため、汎用的な方法で完璧に対応できないのはやむを得ないのかなと思いますが、もしより良い方法をご存知の方はご教示いただければ幸いです。