SDFについて
-
2024年2月27日
はじめに
はじめまして、ココネのクライアントエンジニアのKです。
仕事でUnityを使用していますが、Unity UIを使用してテキストを描画したい場合、TextとTextMeshProの2つの方法があります。
両者の違いは何でしょうか。また、なぜTextMeshProが必要なのでしょうか?
TextMeshProの説明を調べてみると、従来のビットマップの代わりに、より優れたSigned Distance Field (SDF)を使用していることがわかります。
一体、SDFとは何でしょうか?
SDF
Signed Distance Function
その前にSigned Distance Function (SDF)から紹介します。SDFは、空間内の物体を記述する方法です。
- Signed(正or負の値):正値 (内部) 、負値 (外部)
- Distance(距離):物体表面までの距離
SDFを使用してさまざまな形状を記述することができます。いくつかの例を見てみましょう。
円
float sdCircle( vec2 p, float r )
{
return length(p) - r;
}

長方形
float sdBox( in vec2 p, in vec2 b )
{
vec2 d = abs(p)-b;
return length(max(d,0.0)) + min(max(d.x,d.y),0.0);
}

Signed Distance Field
Signed Distance Fieldは、Signed Distance Functionのグリッドサンプリングとして表されます。

SDFフォント
従来のビットマップ フォントでは、テキストを大きくするとぼやけて形が崩れてしまいます。

Valveは2007年にSDFを使用する新しい方法(Improved Alpha-Tested Magnification for Vector Textures and Special Effects)を発表しました。
実際のピクセルの色をテクスチャに保存する代わりに、最初にSDFの情報を保存し、レンダリング時にテキストを再構築します。これは空間ベクトルに似ていますが、GPUによる計算が簡単かつ高速になります。
SDFテクスチャの作成
入力は高解像度のテクスチャです。

それからSDFテクスチャを作成します。テクスチャからSDFを作成する方法はたくさんありますが、最もよく使用される方法は8-points Signed Sequential Euclidean Distance Transform (8ssedt)です。
8ssedt
[0][0][0]
[0][1][1]
[1][1][1]
- 初期化
- 二つのグリッドを用意します。(内部:0、外部:∞)と(内部:∞、外部:0)
[∞][∞][∞]
[∞][0][0]
[0][0][0]
- 距離の計算
[#1][#2][#3]
[#4][ x][#5]
[#6][#7][#8]
xの値は、現在の近隣の距離と近隣までの距離を加えた最小値です。
x = min(#1.distance, ..., #8.distance)
#?.distance = sqrt( (?dx + offset x)^2 + (?dy + offset y)^2 )
隣接するグリッドを順番に比較します。
- - - > < - - - < - - - - - - >
| [?][?][?] | [ ][ ][ ] ^ [ ][ ][ ] ^ [ ][ ][ ]
| [?][x][ ] | [ ][x][?] | [ ][x][?] | [?][x][ ]
v [ ][ ][ ] v [ ][ ][ ] | [?][?][?] | [ ][ ][ ]
- グリッドの結果の結合
distance = grid1.distance - grid2.distance;
詳細な実装については、ここで参照できます。
最後に距離を0から1までに正規化します。ここで0.5が表面です。

レンダリング
距離が 0.5 未満の場合は外側、それ以外の場合は内側、とすることで形状を簡単に描くことができます。
v2f vert (appdata v)
{
v2f o;
o.vertex = TransformObjectToHClip(v.vertex.xyz);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
half4 frag (v2f i) : SV_Target
{
// SDFテクスチャから距離を読み取ります
half4 col = tex2D(_MainTex, i.uv);
float distance = col.a;
// 距離 < 0.5の場合(外部)、alphaを0に設定します
if (distance < _DistanceThreshold)
col.a = 0.0;
else
col.a = 1.0;
col.rgb = _MainColor.rgb;
return col;
}

比較
同じサイズ(310x106)のテクスチャ。(左:SDF、右:ビットマップ)
ビットマップと比較して、SDFは拡大しても形状を維持できています。


効果
アンチエイリアス
col.a = smoothstep(_DistanceThreshold - _SmoothDelta, _DistanceThreshold + _SmoothDelta, distance);
(左:オフ、右:オン)


アウトライン
アウトラインの閾値を追加し、そして最後に2つの値をブレンドします。
half4 outlineCol;
outlineCol.a = smoothstep(_OutlineDistanceThreshold - _SmoothDelta, _OutlineDistanceThreshold + _SmoothDelta, distance);
outlineCol.rgb = _OutlineColor.rgb;
col.a = smoothstep(_DistanceThreshold - _SmoothDelta, _DistanceThreshold + _SmoothDelta, distance);
col.rgb = _MainColor.rgb;
return lerp(outlineCol, col, col.a);

最後に
今回はTextMeshProで使用するSDFを簡単に紹介しました。実際には、特に3Dでレイマーチングを使用してSDFと組み合わせるアプリケーションは数多くあります。
例えば、
- Unity:Signed Distance Fields in the Visual Effect Graph
- Unreal:Mesh Distance Fields
- Godot:Signed distance field global illumination
興味があれば、ShaderToyでSDFについて詳しく学ぶことができます。シェーダを書いてすぐに表示できる良いサイトです。
私もシンプルな形を使ってC.A.T Clubのロゴを作成してみました。

(ShaderToyに貼り付けて表示することもできますので、ぜひお試しください)
// C.A.T Club Logo
vec2 rotate(vec2 uv, float th) {
return mat2(cos(th), sin(th), -sin(th), cos(th)) * uv;
}
// 2D functions are from https://iquilezles.org/articles/distfunctions2d
float ndot(vec2 a, vec2 b ) { return a.x*b.x - a.y*b.y; }
float sdRhombus( in vec2 p, in vec2 b )
{
p = abs(p);
float h = clamp( ndot(b-2.0*p,b)/dot(b,b), -1.0, 1.0 );
float d = length( p-0.5*b*vec2(1.0-h,1.0+h) );
return d * sign( p.x*b.y + p.y*b.x - b.x*b.y );
}
float sdTriangleIsosceles( in vec2 p, in vec2 q )
{
p.x = abs(p.x);
vec2 a = p - q*clamp( dot(p,q)/dot(q,q), 0.0, 1.0 );
vec2 b = p - q*vec2( clamp( p.x/q.x, 0.0, 1.0 ), 1.0 );
float s = -sign( q.y );
vec2 d = min( vec2( dot(a,a), s*(p.x*q.y-p.y*q.x) ),
vec2( dot(b,b), s*(p.y-q.y) ));
return -sqrt(d.x)*sign(d.y);
}
float sdRing( in vec2 p, in vec2 n, in float r, in float th )
{
p.x = abs(p.x);
p = mat2(n.x,n.y,-n.y,n.x)*p;
return max( abs(length(p)-r)-th*0.5,
length(vec2(p.x,max(0.0,abs(r-p.y)-th*0.5)))*sign(p.x) );
}
vec3 drawScene(vec2 uv) {
vec3 col = vec3(1);
float rhombus = sdRhombus(uv, vec2(0.55, 0.35));
float t = 3.14159 * 0.72;
float ring = sdRing(rotate(uv, -1.57), vec2(cos(t),sin(t)), 0.15, 0.1);
float triangle1 = sdTriangleIsosceles(rotate(uv + vec2(0.18, -0.28), 1.57), vec2(0.13, -0.23));
float triangle2 = sdTriangleIsosceles(rotate(uv + vec2(-0.18, -0.28), -1.57), vec2(0.13, -0.23));
col = mix(vec3(0.19, 0.15, 0.11), col, step(0., rhombus));
col = mix(vec3(0.99, 0.86, 0.39), col, step(0., ring));
col = mix(vec3(0.19, 0.15, 0.11), col, step(0., triangle1));
col = mix(vec3(0.19, 0.15, 0.11), col, step(0., triangle2));
return col;
}
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
vec2 uv = fragCoord/iResolution.xy; // (0, 1)
uv -= 0.5; // (-0.5,0.5)
uv.x *= iResolution.x/iResolution.y; // fix aspect ratio
vec3 col = drawScene(uv);
fragColor = vec4(col,1.0);
}