第 9 課 - 楕円と矩形を描く
この課では、次の内容を学びます。
- 楕円と角丸矩形の SDF 表現を導出する
- 角丸矩形とその他の SDF にドロップシャドウとインナーシャドウを追加する
- 任意の点が楕円または角丸矩形の内側にあるかを判定する
第 2 課 では SDF で円を描きました。これを楕円と矩形へ広げるのは簡単です。2D distance functions には、2D 図形の SDF の式がさらに載っています。
float sdf_ellipse(vec2 p, vec2 r) {}
float sdf_rounded_box(vec2 p, vec2 b, vec4 r) {}シェーダーでは shape 変数でこの 3 つの図形を区別するので、同じシェーダー一式で描けます。
if (shape < 0.5) {
outerDistance = sdf_circle(v_FragCoord, 1.0);
innerDistance = sdf_circle(v_FragCoord, r.x);
} else if (shape < 1.5) {
outerDistance = sdf_ellipse(v_FragCoord, vec2(wh, 1.0));
innerDistance = sdf_ellipse(v_FragCoord, r);
} else if (shape < 2.5) {
outerDistance = sdf_rounded_box(v_FragCoord, vec2(wh, 1.0), 0.0);
innerDistance = sdf_rounded_box(v_FragCoord, r, 0.0);
}次に、SDF がどのように導かれるかを見ていきます。
矩形
The SDF of a Box と Leveraging Rust and the GPU to render user interfaces at 120 FPS では、導出の過程が動画とアニメーションで示されています。
矩形の対称性を使い、中心を原点に置くと、任意の点から矩形の辺までの距離の問題を abs() 関数で第 1 象限の問題に移せます。ここで p は任意の点の座標、b は矩形の右上の頂点の座標で、中心が原点と一致するので [width / 2, height / 2] です。q は、その点から右上の頂点へ向かうベクトルです。
float sdf_box(vec2 p, vec2 b) {
vec2 q = abs(p) - b;
}
続いて、点が矩形の外側にある場合を考えます。矩形の右上の頂点から外側へ伸ばすと、4 つの象限に分けられます。下の図の第 1 象限では、距離は q ベクトルの長さ length(q) です。点が第 2 象限に入ると q.x が負なので距離は q.y になり、同様に第 4 象限に入ると距離は q.x です。

原著者は length(max(q, 0.0)) を使って、この 3 つの場合をうまく一つにまとめました。max() で負の成分を消し、シェーダー内の分岐判定をできるだけ減らします。続いて、点が矩形の内側にある場合、つまり上の図の第 3 象限を考えます。このとき q の 2 つの成分はどちらも負で、max(q.x, q.y) によって、より近い距離の絶対値を得られます。一番外側の min() は、点が内側にある場合を先の 3 つの場合と一つにまとめます。これも分岐判定を減らすためです。完全な SDF は次のとおりです。
float sdf_box(vec2 p, vec2 b) {
vec2 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
}ここまでで矩形を描けます。SVG <rect> を参考に、次のプロパティを追加します。
const rect = new Rect({
x,
y,
width,
height,
fill,
});角を丸くする
下の図は Rounding Corners in SDFs からのものです。等高線で可視化した距離場を見ると、矩形そのものがすでに丸みを帯びていることがわかります。矩形の右上付近の点を例にすると、距離が等しい点は 1 つではなく、右上の頂点を中心とする円の上に並んでいます。

実際、矩形だけでなく、SDF で表されるすべての図形を「丸めた」版に変換できます。下の図は 2D distance functions からのものです。
float opRound( in vec2 p, in float r ) {
return sdShape(p) - r;
}
したがって、角丸矩形の完全な SDF 表現は次のとおりです。
float sdf_rounded_box(vec2 p, vec2 b, float r) {
vec2 q = abs(p) - b + r;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
}Figma の命名に合わせて cornerRadius を使いますが、SDF だけでは smoothing の効果は出せません。詳しくは Adjust corner radius and smoothing と Desperately seeking squircles を参照してください。さらに、角ごとに異なる半径をサポートすることもできます。Zed Blade WGSL を参照してください。これは Zed が blade レンダラーを基に書いたシェーダーで、WGSL の構文を使っています。
$icCanvas2 = call(() => {
return document.createElement('ic-canvas-lesson9');
});call(() => {
const { Canvas, Rect } = Lesson9;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas2.parentElement.style.position = 'relative';
$icCanvas2.parentElement.appendChild($stats);
$icCanvas2.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
for (let i = 0; i < 1000; i++) {
const fill = `rgb(${Math.floor(Math.random() * 255)},${Math.floor(
Math.random() * 255,
)},${Math.floor(Math.random() * 255)})`;
const rect = new Rect({
x: Math.random() * 1000,
y: Math.random() * 1000,
fill,
cornerRadius: 10,
});
// rect.x = Math.random() * 1000;
// rect.y = Math.random() * 1000;
rect.width = Math.random() * 40;
rect.height = Math.random() * 40;
canvas.appendChild(rect);
rect.addEventListener('pointerenter', () => {
rect.fill = 'red';
});
rect.addEventListener('pointerleave', () => {
rect.fill = fill;
});
}
});
$icCanvas2.addEventListener('ic-frame', (e) => {
stats.update();
});
});ドロップシャドウを追加する
影といえば、CSS の box-shadow と filter: drop-shadow() を聞いたことがあるかもしれません。下の図は Drop-Shadow: The Underrated CSS Filter からのもので、両者の違いを直感的に示しています。

後者のほうが一般的によく使われ、tailwindcss - Drop Shadow がその例です。そこで矩形に次のプロパティを追加します。
rect.dropShadowColor = 'black';
rect.dropShadowOffsetX = 10;
rect.dropShadowOffsetY = 10;
rect.dropShadowBlurRadius = 5;
次に、WebGL / WebGPU で 2D 図形の影を描きます。一般的な方法はポストプロセスでガウスぼかしを使うことで、Pixi.js の DropShadowFilter がその例です。2D のガウスぼかしは、水平方向と垂直方向の 2 つの 1D 効果に分解できますが、畳み込み演算では依然として隣接ピクセルをサンプリングする必要があります。サンプル数は畳み込みカーネルの大きさに依存します。
Figma の CTO、Evan Wallace は、Fast Rounded Rectangle Shadows でより速い近似法を紹介しました。テクスチャのサンプリングは不要で、Leveraging Rust and the GPU to render user interfaces at 120 FPS でもより詳しく説明されています。ガウス関数とステップ関数の畳み込みはガウス関数の積分に等しく、その結果は誤差関数 Error function(erf とも呼ばれます)になります。したがって、ぼかした矩形を作ることは、角の半径は考えずに各次元を別々にぼかしてから、2 つの結果の交差を取ることに相当します。
ガウス関数は次のとおりです。
誤差関数はガウス関数の積分で、正規分布の累積分布関数を表すために使います。
誤差関数のよく使われる近似は Abramowitz and Stegun. Handbook of Mathematical Functions. に由来します。
多項式の各項の係数は次のとおりです。
次の実装は Zed Blade WGSL からのもので、これを GLSL で書き直しました。Blurred rounded rectangles も、erf の別バージョンを示しています。
vec2 erf(vec2 x) {
vec2 s = sign(x), a = abs(x);
x = 1.0 + (0.278393 + (0.230389 + 0.078108 * (a * a)) * a) * a;
x *= x;
return s - s / (x * x);
}ひとまず角の丸みは置いておき、最終的な影のマスク値を計算します。ここで影の境界は integral_x と integral_y の差で決まります。integral_x.x - integral_x.y は x 軸上の影の幅を計算し、integral_y.x - integral_y.y は y 軸上の影の高さを計算します。この 2 つの値を掛けると、最終的な影のマスク値になります。
// Return the mask for the shadow of a box from lower to upper
float rect_shadow(vec2 pixel_position, vec2 origin, vec2 size, float sigma) {
vec2 bottom_right = origin + size;
vec2 x_distance = vec2(pixel_position.x - origin.x, pixel_position.x - bottom_right.x);
vec2 y_distance = vec2(pixel_position.y - origin.y, pixel_position.y - bottom_right.y);
vec2 integral_x = 0.5 + 0.5 * erf(x_distance * (sqrt(0.5) / sigma));
vec2 integral_y = 0.5 + 0.5 * erf(y_distance * (sqrt(0.5) / sigma));
return (integral_x.x - integral_x.y) * (integral_y.x - integral_y.y);
}しかし、上のような閉じた形の解は、角丸矩形とガウス関数の 2 次元畳み込みには存在しません。角丸矩形の式が分離可能ではないからです。Evan Wallace の近似が巧妙なのは、一方の軸に沿って閉じた形の厳密な畳み込みを行い、その後、反対の軸に沿ってガウス関数を有限回だけ手動でずらしながら計算する点にあります。
float blur_along_x(float x, float y, float sigma, float corner, vec2 half_size) {
float delta = min(half_size.y - corner - abs(y), 0.);
float curved =
half_size.x - corner + sqrt(max(0., corner * corner - delta * delta));
vec2 integral =
0.5 + 0.5 * erf((x + vec2(-curved, curved)) * (sqrt(0.5) / sigma));
return integral.y - integral.x;
}// The signal is only non-zero in a limited range, so don't waste samples
float low = center_to_point.y - half_size.y;
float high = center_to_point.y + half_size.y;
float start = clamp(-3. * blur_radius, low, high);
float end = clamp(3. * blur_radius, low, high);
// Accumulate samples (we can get away with surprisingly few samples)
float step = (end - start) / 4.;
float y = start + step * 0.5;
for (int i = 0; i < 4; i++) {
alpha += blur_along_x(center_to_point.x, center_to_point.y - y, blur_radius,
cornerRadius, half_size) *
gaussian(y, blur_radius) * step;
y += step;
}実装では、矩形ごとに影を個別に描く必要があり、前にまとめたバッチの効果が崩れます。描画順を厳密に守らなければならず、再描画の前に毎回並べ替える必要さえあるからです。次のコードは Fast Rounded Rectangle Shadows からのものです。描く前に、あらかじめ決めた深度ですべての矩形をソートし、影と本体を順に描きます。
render() {
boxes.sort(function(a, b) {
return a.depth - b.depth;
});
for (var i = 0; i < boxes.length; i++) {
boxes[i].callback(); // Draw shadow first and then the rectangle itself.
}
}例として、次の 2 つの矩形は次の順で描きます。緑の矩形の影、緑の矩形、赤の矩形の影、赤の矩形です。以前の考え方どおり、2 つの影と 2 つの矩形本体をそれぞれ別々の 2 つのバッチにまとめて描くと、赤の矩形の影を緑の矩形の上に落とすことができません。そのため使うときには、影のある矩形に batchable = false を設定する必要があります。
$icCanvas3 = call(() => {
return document.createElement('ic-canvas-lesson9');
});call(() => {
const { Canvas, Rect } = Lesson9;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas3.parentElement.style.position = 'relative';
$icCanvas3.parentElement.appendChild($stats);
$icCanvas3.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
const rect = new Rect({
x: 50,
y: 50,
fill: 'green',
cornerRadius: 50,
batchable: false,
dropShadowColor: 'black',
dropShadowOffsetX: 10,
dropShadowOffsetY: 10,
dropShadowBlurRadius: 10,
});
rect.width = 400;
rect.height = 100;
canvas.appendChild(rect);
const rect2 = new Rect({
x: 100,
y: 100,
fill: 'red',
batchable: false,
cornerRadius: 50,
dropShadowColor: 'black',
dropShadowBlurRadius: 10,
});
rect2.width = 400;
rect2.height = 100;
canvas.appendChild(rect2);
});
$icCanvas3.addEventListener('ic-frame', (e) => {
stats.update();
});
});もう一つ注意する点は、影のぼかし半径があるため、矩形を元のサイズから一回り外側へ広げる必要があることです。ここでは 3 * dropShadowBlurRadius にしています。
float margin = 3.0 * dropShadow.z;
origin += dropShadow.xy;
v_Origin = origin;
v_Size = size;
origin -= margin;
size += 2.0 * margin;
vec2 center = origin + size / 2.0;
v_Point = center + a_FragCoord * (size / 2.0);最後に、影は RenderBounds の計算にも影響します。そうしないと、矩形の本体はビューポートの外にあっても影はビューポートの中にあるときに、矩形が誤ってカリングされます。
this.renderBounds.addBounds(
new AABB(
x + dropShadowOffsetX - dropShadowBlurRadius,
y + dropShadowOffsetY - dropShadowBlurRadius,
x + dropShadowOffsetX + width + dropShadowBlurRadius,
y + dropShadowOffsetY + height + dropShadowBlurRadius,
),
);この方法を基に、興味深い効果も作れます。Shape Lens Blur Effect with SDFs and WebGL を参照してください。
明らかに、上の方法は角丸矩形にしか使えません。円、楕円、その他の SDF 表現にも使える、より一般的な方法はあるでしょうか。Shadertoy に Drop shadow of rounded rect という例があります。興味深いことに、この例を基にして、外側の影と内側の影の両方を実装した別の例もあります。以下ではインナーシャドウの実装に焦点を当てます。
インナーシャドウを追加する
下の画像は Figma のインナーシャドウで、Button のような UI コンポーネントによく使われます。

次の属性を追加します。
rect.innerShadowColor = 'black';
rect.innerShadowOffsetX = 10;
rect.innerShadowOffsetY = 10;
rect.innerShadowBlurRadius = 5;Shadertoy の例 Inner shadow of rounded rect を参考に、現在の 3 つの図形それぞれへ、同じように影を描くロジックを追加します。ここでは滑らかにするために sigmoid を使います。
float make_shadow(vec2 pos, vec2 halfSize, float cornerRd, float blurRd, float distMul, float shape) {
float distance;
if (shape < 0.5) {
distance = sdf_circle(pos, halfSize.x);
} else if (shape < 1.5) {
distance = sdf_ellipse(pos, halfSize);
} else if (shape < 2.5) {
distance = sdf_rounded_box(pos, halfSize, cornerRd + blurRd);
}
float dist = sigmoid(distMul * distance / blurRd);
return clamp(dist, 0.0, 1.0);
}$icCanvas4 = call(() => {
return document.createElement('ic-canvas-lesson9');
});call(() => {
const { Canvas, Rect, Circle, Ellipse } = Lesson9;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas4.parentElement.style.position = 'relative';
$icCanvas4.parentElement.appendChild($stats);
$icCanvas4.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
for (let i = 0; i < 10; i++) {
const fill = `rgb(${Math.floor(Math.random() * 255)},${Math.floor(
Math.random() * 255,
)},${Math.floor(Math.random() * 255)})`;
const rect = new Rect({
x: Math.random() * 1000,
y: Math.random() * 1000,
fill,
cornerRadius: 50,
innerShadowColor: 'black',
innerShadowOffsetX: Math.random() * 20 - 10,
innerShadowOffsetY: Math.random() * 20 - 10,
innerShadowBlurRadius: Math.random() * 10,
});
rect.width = 200;
rect.height = 100;
canvas.appendChild(rect);
const circle = new Circle({
cx: Math.random() * 1000,
cy: Math.random() * 1000,
r: 100,
fill,
innerShadowColor: 'black',
innerShadowOffsetX: Math.random() * 20 - 10,
innerShadowOffsetY: Math.random() * 20 - 10,
innerShadowBlurRadius: Math.random() * 10,
});
canvas.appendChild(circle);
const ellipse = new Ellipse({
cx: Math.random() * 1000,
cy: Math.random() * 1000,
rx: 100,
ry: 50,
fill,
innerShadowColor: 'blue',
innerShadowOffsetX: Math.random() * 20 - 10,
innerShadowOffsetY: Math.random() * 20 - 10,
innerShadowBlurRadius: Math.random() * 10,
});
canvas.appendChild(ellipse);
}
});
$icCanvas4.addEventListener('ic-frame', (e) => {
stats.update();
});
});楕円
任意の点から円までの最近距離を求めるのとは違い、楕円を解析的に厳密に扱う方法ははるかに複雑です。Distance to an ellipse は 2 つの方法を示しています。四次方程式とニュートン法です。
四次方程式
楕円は
展開すると次のとおりです。
最近点では導関数が 0 です。
これは
四次の項の係数を 1 にして Monic Polynomial を得るため、上の各係数を
こうして、係数が次のような Quartic Equation になります。
最高次の係数が 1 なので、Resolvent Cubic を使って、三次方程式 Cubic Formula を解く問題に変換できます。
係数の対応は次のとおりです。
代入すると次のとおりです。
Cubic Formula を解くときは、二次の項の係数を消そうとします。
上の式をさらに簡単にするため、次のように置きます。
標準形は
この方法は計算コストが大きく、Distance to an ellipse に示された ShaderToy の例は、以下で紹介する 2 つの方法より FPS がずっと低くなります。
ニュートン法
任意の点
このうち、導関数は次のとおりです。
代入すると次のとおりです。
Newton's method は、実数と複素数の範囲で方程式を近似的に解く方法です。
代入すると次のとおりです。
Distance to an ellipse の原著者は、5 回の反復で非常によい結果を得ています。
for (int i=0; i<5; i++) {
vec2 cs = vec2(cos(w),sin(w));
vec2 u = ab*vec2( cs.x,cs.y);
vec2 v = ab*vec2(-cs.y,cs.x);
w = w + dot(p-u,v)/(dot(p-u,u)+dot(v,v));
}以下で必要なのは
bool s = dot(p/ab,p/ab)>1.0;楕円の外側なら、円へ引き伸ばせます。次の小節で説明する手法でもこれを使います。このとき
楕円の内側なら、半長軸に対応する
float w = s ? atan(p.y*ab.x, p.x*ab.y) :
((ab.x*(p.x-ab.x)<ab.y*(p.y-ab.y))? 1.5707963 : 0.0);前に一変数の四次方程式を解く方法と比べると、この方法のコストはより小さくなります。それでも、反復回数が固定された for ループが含まれ、GLSL をコンパイルするときにそのループは展開されます。Loop performance in a shader を参照してください。 最後に、コストが最も小さい近似推定の方法を紹介します。
円へ引き伸ばす近似法
Ellipsoid SDF は、単位円へ引き伸ばしてから楕円へ戻すこの方法を説明しています。戻すとき、引き伸ばしの係数はどう選べばよいでしょうか。点が X 軸上にあれば係数は r.x、Y 軸上にあれば r.y です。まずこの 2 つのうち小さい方を選びます。
float sdf_ellipse_V1( in vec2 p, in vec2 r )
{
float k1 = length(p/r);
return (k1-1.0)*min(r.x,r.y);
}実際にレンダリングすると、この方法は長軸のところで問題が目立ち、うまく沿いません。

原著者が改良版を示しています。
float sdf_ellipse_V3( in vec3 p, in vec3 r )
{
float k1 = length(p/r);
return length(p)*(1.0-1.0/k1);
}しかし実際にレンダリングすると、この方法は縁に明らかなギザギザが出ます。

最終的に著者は勾配を考慮する項を加え、私たちもこの方法を使います。
float sdf_ellipse_V2( in vec2 p, in vec2 r )
{
float k1 = length(p/r);
float k2 = length(p/(r*r));
return k1*(k1-1.0)/k2;
}結果はずっと良くなっていることがわかります。
$icCanvas = call(() => {
return document.createElement('ic-canvas-lesson9');
});call(() => {
const { Canvas, Ellipse } = Lesson9;
const stats = new Stats();
stats.showPanel(0);
const $stats = stats.dom;
$stats.style.position = 'absolute';
$stats.style.left = '0px';
$stats.style.top = '0px';
$icCanvas.parentElement.style.position = 'relative';
$icCanvas.parentElement.appendChild($stats);
$icCanvas.addEventListener('ic-ready', (e) => {
const canvas = e.detail;
for (let i = 0; i < 1000; i++) {
const fill = `rgb(${Math.floor(Math.random() * 255)},${Math.floor(
Math.random() * 255,
)},${Math.floor(Math.random() * 255)})`;
const ellipse = new Ellipse({
cx: Math.random() * 1000,
cy: Math.random() * 1000,
rx: Math.random() * 20,
ry: Math.random() * 20,
fill,
});
canvas.appendChild(ellipse);
ellipse.addEventListener('pointerenter', () => {
ellipse.fill = 'red';
});
ellipse.addEventListener('pointerleave', () => {
ellipse.fill = fill;
});
}
});
$icCanvas.addEventListener('ic-frame', (e) => {
stats.update();
});
});ピッキング
現在のピッキングプラグインは 幾何学的手法 を使うので、楕円と角丸矩形には判定方法を別々に実装する必要があります。後で、より一般的な、色で符号化する GPU ベースのピッキング方法を紹介します。上の 2 つの例では、任意の図形の上にマウスを乗せてピッキングの効果を確かめられます。
楕円
楕円のピッキング判定はより単純です。たとえば既定では、塗りの領域とストロークの領域の両方を考慮します。
function isPointInEllipse(
x: number,
y: number,
h: number,
k: number,
a: number,
b: number,
) {
const dx = x - h;
const dy = y - k;
const squaredDistance = (dx * dx) / (a * a) + (dy * dy) / (b * b);
// Inside ellipse
return squaredDistance <= 1;
}角丸矩形
角の丸みを考慮しなければ、これも非常に単純です。
function isPointInRoundedRectangle(
x: number,
y: number,
x1: number,
y1: number,
x2: number,
y2: number,
r: number,
) {
// Determine if a point is inside the corners of a rectangle.
function isInsideCorner(
x: number,
y: number,
cornerX: number,
cornerY: number,
r: number,
) {
const distance = Math.sqrt(
Math.pow(x - cornerX, 2) + Math.pow(y - cornerY, 2),
);
return distance <= r;
}
// Determine if a point is inside a rounded rectangle
if (x >= x1 && x <= x2 && y >= y1 && y <= y2) {
// Points inside the rectangle
if (
isInsideCorner(x, y, x1 + r, y1 + r, r) || // top-left
isInsideCorner(x, y, x2 - r, y1 + r, r) || // top-right
isInsideCorner(x, y, x2 - r, y2 - r, r) || // bottom-right
isInsideCorner(x, y, x1 + r, y2 - r, r) // bottom-left
) {
return true; // Points inside the corner
}
return !(
x <= x1 + r ||
x >= x2 - r || // The point is on the non-circular boundary of the rectangle
y <= y1 + r ||
y >= y2 - r
);
}
return false; // Points outside the rectangle
}