Skip to main content
GDN8 PATIO

いろいろなBlur実装と負荷の比較

Fragment ShaderでBlurをつくる方法を比べます。同じTexture参照回数でもサンプルを置く方向と重みで見た目は変わります。この記事では1方向の畳み込みと1 Passで周囲を参照するKernelを扱います。画質の順位を決めるのではなく必要なぼけ方と許容できる負荷から実装を選ぶための比較です。

掲載コードの扱い

BlurやGaussian Convolutionのような手法とその手法を表現した個別のソースコードは分けて考える必要があります。一般的な数式やアルゴリズムを説明することと外部のShaderコードをコピーすることは同じではありません。この区別は文化庁による著作物利用の整理も参考にしています。

今回のコードは第三者のShaderを転載せず次の規則から比較用に書き起こしています。

  • Gaussian近似のBinomial KernelはPascalの三角形から得られる整数列を合計1へ正規化
  • 円周上のサンプルは正多角形の座標から作成
  • Spiralの座標はGolden Angleと平方根半径から計算

出所やライセンスが確認できない実装は掲載していません。外部ライブラリのコードをプロジェクトへ持ち込む場合はそのライセンスを別途確認します。たとえばMIT Licenseのコードを複製または改変して配布する場合は著作権表示と許諾表示を残す条件があります。

  • ※ ここでの説明は記事を公開する際の一般的な整理です。個別案件の法的判断ではありません
  • ※ 「参考としてリンクしたこと」と「そのリンク先のコードを転載できること」は別です

相対負荷の見方

相対負荷は5回Textureを参照する1 Passを1とした概算です。実測したGPU時間ではありません。

実装 Texture参照 / Pass Pass 相対負荷 Sampling
BLUR_5_BINOMIAL 5 2 1 / Pass 1方向
BLUR_9_BINOMIAL 9 2 1.8 / Pass 1方向
BLUR_5_DIAGONAL 5 1 1 対角4方向
BLUR_7_HEX 7 1 1.4 6方向
BLUR_9_OCTAGON 9 1 1.8 8方向
BLUR_9_SPIRAL 9 1 1.8 Spiral
BLUR_17_MULTI_RING 17 1 3.4 2 Rings

BLUR_5_BINOMIALBLUR_9_BINOMIALのPass数は2次元のBlurとして使う場合です。HorizontalとVerticalの2 Passに分けるため、合計のTexture参照はそれぞれ10回と18回になります。

  • ※ Render Targetの切り替え、元のScene、Texture Format、Sampling Cacheは相対負荷へ含めていません
  • ※ Device Pixel Ratioが2になるとDPR 1に対して処理するピクセル数は4倍です
  • ※ 最終的な負荷は対象端末でGPU時間を計測して判断します

共通の前提

以下はWebGL 2 / GLSL ES 3.00を前提とします。texelはTexture上の1ピクセルです。

vec2 texel = 1.0 / textureSize;

1方向のKernelではstepUvへ1サンプル分の移動量を渡します。

vec2 stepX = vec2(texel.x, 0.0) * radius;
vec2 stepY = vec2(0.0, texel.y) * radius;

2次元Kernelで使う8方向は共通化します。斜め方向は長さが1になるよう正規化しています。

const float INV_SQRT_2 = 0.70710678118;

const vec2 OCTAGON[8] = vec2[8](
	vec2(1.0, 0.0),
	vec2(INV_SQRT_2, INV_SQRT_2),
	vec2(0.0, 1.0),
	vec2(-INV_SQRT_2, INV_SQRT_2),
	vec2(-1.0, 0.0),
	vec2(-INV_SQRT_2, -INV_SQRT_2),
	vec2(0.0, -1.0),
	vec2(INV_SQRT_2, -INV_SQRT_2)
);

Textureの端ではWrap Modeの影響を受けます。画面用のRender Targetで外側へ色を繰り返したくない場合はClampToEdgeWrappingにします。

BLUR_5_BINOMIAL

相対負荷:1 / Pass

Pascalの三角形にある1, 4, 6, 4, 1を合計の16で割った5 Tap Kernelです。中心の重みは6 / 16、1ピクセル外側は4 / 16、2ピクセル外側は1 / 16になります。

vec3 blur5Binomial(
	sampler2D image,
	vec2 uv,
	vec2 stepUv
) {
	vec3 color = texture(image, uv).rgb * 0.375;
	color += texture(image, uv + stepUv).rgb * 0.25;
	color += texture(image, uv - stepUv).rgb * 0.25;
	color += texture(image, uv + stepUv * 2.0).rgb * 0.0625;
	color += texture(image, uv - stepUv * 2.0).rgb * 0.0625;
	return color;
}

1 PassではstepUvの方向だけに伸びます。2次元のGaussian近似として使う場合は横方向の結果をRender Targetへ保存し、そのTextureへ縦方向のPassを適用します。

BLUR_9_BINOMIAL

相対負荷:1.8 / Pass

9 Tapでは1, 8, 28, 56, 70, 56, 28, 8, 1を合計の256で割ります。5 Tapより外側まで滑らかに重みが続きます。

vec3 blur9Binomial(
	sampler2D image,
	vec2 uv,
	vec2 stepUv
) {
	vec3 color = texture(image, uv).rgb * 0.2734375;
	color += texture(image, uv + stepUv).rgb * 0.21875;
	color += texture(image, uv - stepUv).rgb * 0.21875;
	color += texture(image, uv + stepUv * 2.0).rgb * 0.109375;
	color += texture(image, uv - stepUv * 2.0).rgb * 0.109375;
	color += texture(image, uv + stepUv * 3.0).rgb * 0.03125;
	color += texture(image, uv - stepUv * 3.0).rgb * 0.03125;
	color += texture(image, uv + stepUv * 4.0).rgb * 0.00390625;
	color += texture(image, uv - stepUv * 4.0).rgb * 0.00390625;
	return color;
}

係数の合計は1.0です。均一な色を入力したときに明るさが変わらないため、Kernelの正規化漏れも確認できます。

BLUR_5_DIAGONAL

相対負荷:1

中心と対角4方向を同じ重みで参照します。5回のTexture参照だけで2次元へ広げられます。

vec3 blur5Diagonal(
	sampler2D image,
	vec2 uv,
	vec2 texel,
	float radius
) {
	vec2 offset = texel * radius * INV_SQRT_2;
	vec3 color = texture(image, uv).rgb * 0.2;
	color += texture(image, uv + vec2(offset.x, offset.y)).rgb * 0.2;
	color += texture(image, uv + vec2(-offset.x, offset.y)).rgb * 0.2;
	color += texture(image, uv + vec2(-offset.x, -offset.y)).rgb * 0.2;
	color += texture(image, uv + vec2(offset.x, -offset.y)).rgb * 0.2;
	return color;
}

少ないサンプル数で広がる一方、半径を大きくすると対角方向の形が見えます。小さな半径で使うか、解像度を下げたTextureへ複数回適用する方法が向いています。

BLUR_7_HEX

相対負荷:1.4

中心と60度間隔の6方向を参照します。中心の重みを0.25、円周上をそれぞれ0.125にすると合計は1.0です。

vec3 blur7Hex(
	sampler2D image,
	vec2 uv,
	vec2 texel,
	float radius
) {
	const float SIN_60 = 0.86602540378;
	vec2 scale = texel * radius;
	vec3 color = texture(image, uv).rgb * 0.25;

	color += texture(image, uv + scale * vec2(1.0, 0.0)).rgb * 0.125;
	color += texture(image, uv + scale * vec2(0.5, SIN_60)).rgb * 0.125;
	color += texture(image, uv + scale * vec2(-0.5, SIN_60)).rgb * 0.125;
	color += texture(image, uv + scale * vec2(-1.0, 0.0)).rgb * 0.125;
	color += texture(image, uv + scale * vec2(-0.5, -SIN_60)).rgb * 0.125;
	color += texture(image, uv + scale * vec2(0.5, -SIN_60)).rgb * 0.125;

	return color;
}

縦横や対角4方向より円へ近い広がりになります。大きな半径では六角形の輪郭が残るので、それをBokehの形として利用することもできます。

BLUR_9_OCTAGON

相対負荷:1.8

中心と8方向の円周を参照します。Loopは固定8回なのでShader Compilerが展開しやすい構成です。

vec3 blur9Octagon(
	sampler2D image,
	vec2 uv,
	vec2 texel,
	float radius
) {
	vec2 scale = texel * radius;
	vec3 color = texture(image, uv).rgb * 0.2;

	for(int i = 0; i < 8; i++) {
		color += texture(image, uv + OCTAGON[i] * scale).rgb * 0.1;
	}

	return color;
}

すべての円周サンプルが同じ半径にあるため、輪郭の周囲にRingが見えることがあります。中心の重みを増やす場合は円周側を減らし、合計を1.0に保ちます。

BLUR_9_SPIRAL

相対負荷:1.8

サンプルを1本のRingへ置かず中心から外側へ分散します。座標は次の式で計算した値を定数にしています。

angle(i) = i × π × (3 − √5)
radius(i) = √(i / 8)

Fragment Shader内では三角関数を評価しません。

const vec2 SPIRAL_9[9] = vec2[9](
	vec2(0.0000000, 0.0000000),
	vec2(-0.2606993, 0.2388219),
	vec2(0.0437129, -0.4980855),
	vec2(0.3725912, 0.4859792),
	vec2(-0.6962976, -0.1231652),
	vec2(0.6670471, -0.4243208),
	vec2(-0.2248239, 0.8363338),
	vec2(-0.4311390, -0.8301320),
	vec2(0.9393213, 0.3430386)
);

vec3 blur9Spiral(
	sampler2D image,
	vec2 uv,
	vec2 texel,
	float radius
) {
	vec3 color = vec3(0.0);

	for(int i = 0; i < 9; i++) {
		color += texture(
			image,
			uv + SPIRAL_9[i] * texel * radius
		).rgb;
	}

	return color / 9.0;
}

固定軸や単一Ringの形を減らせます。ただ9点で広い円盤を覆うため、大きな半径ではサンプルの偏りが見えます。

BLUR_17_MULTI_RING

相対負荷:3.4

中心と8方向のRingを2層使います。内側のRingへ大きな重みを置き、外側はぼけの広がりを補います。

vec3 blur17MultiRing(
	sampler2D image,
	vec2 uv,
	vec2 texel,
	float radius
) {
	vec2 scale = texel * radius;
	vec3 color = texture(image, uv).rgb * 0.2;

	for(int i = 0; i < 8; i++) {
		color += texture(
			image,
			uv + OCTAGON[i] * scale * 0.45
		).rgb * 0.075;

		color += texture(
			image,
			uv + OCTAGON[i] * scale
		).rgb * 0.025;
	}

	return color;
}

重みは0.2 + 0.075 × 8 + 0.025 × 8 = 1.0です。25 Tapまで増やす前に、17 Tapと低解像度のRender Targetで必要な見た目になるかを確認します。

使い分け

  • 小さく均一なBlur:BLUR_5_BINOMIALを縦横へ2 Pass
  • 1方向を長く伸ばす:BLUR_9_BINOMIAL
  • 5 Tapで2次元へ広げる:BLUR_5_DIAGONAL
  • 多角形のBokeh:BLUR_7_HEXまたはBLUR_9_OCTAGON
  • 固定方向の形を減らす:BLUR_9_SPIRAL
  • 1 Passのまま内外へ広げる:BLUR_17_MULTI_RING

大きなBlurではKernelを増やす前にRender Targetの解像度を下げます。縦横を半分にすると処理するピクセル数は4分の1で低解像度で小さなKernelを複数Passかけた方がFull Resolutionの大きなKernelより軽くなる場合があります。

参考