はじめに
最近、閾値ありのピクセルソートをリアルタイム実装したくて躍起になっていた。結局、閾値でせき止めを行う奇遇転置ソートでそこそこな速度の物ができた。
しかし、1920x1080で実行すると元が奇遇転置ソートであるためかなり遅い。(RTX 2070で900x500のサイズが30fpsほど)
そこで私も、リサイズしたものを使うことで処理速度を誤魔化すことにした。
Unityでテクスチャのリサイズ方法を検索すると、CPUで自己実装するのが一般的だった。
しかしまあGPU実装した方が動作が軽いので、Compute Shaderで画像のリサイズを実装してみた。
今回の記事内容のプロジェクトはこちら。
https://github.com/Kuyuri-Iroha/asdf_pixel_sort_unity
3つの補間アルゴリズム
今回はせっかくなので補間をするアルゴリズムを
- Nearest neighbor
- Bilinear
- Bicubic
の3つ実装した。
Nearest neighbor
ニアレストネイバー
このアルゴリズムは、出力テクスチャの座標を入力テクスチャの座標にマッピングして、出力テクスチャの座標の最近傍点の入力テクスチャ色を出力とするもの。
これはマッピングさえできてしまえば、あとは1点を抽出するだけなので簡単。
今回行った実装は、floorと同じ挙動でサンプリング点を決めている。これを、0.5を超えたらceilに変えて四捨五入にしてもよかったが、ピクセルパーフェクトを使う時に不整合が出ると嫌なので、とりあえずそのままにしている。
実装は以下のようになっている。
// Nearest neighbor
void NearestNeighborInterpolation(uint2 id)
{
uint2 srcSize;
src.GetDimensions(srcSize.x, srcSize.y);
uint2 destSize;
dest.GetDimensions(destSize.x, destSize.y);
if(destSize.x <= id.x || destSize.y <= id.y)
return;
// ここからNearest neghborの補間処理
float2 sampleUnit = float2(float(srcSize.x) / destSize.x, float(srcSize.y) / destSize.y);
dest[id.xy] = src[min(uint2(sampleUnit.x * id.x, sampleUnit.y * id.y), srcSize)];
}
まず、引数のid
はテクスチャ座標を受け取っており、補間処理の前に入力テクスチャサイズと出力テクスチャサイズを取得する。また、このコンピュートシェーダのスレッド数を2次元にして、
$スレッド数 \cdot グループ数 \leq 出力ピクセル数$
となるようにグループ数を決定する。そのとき、余剰スレッドは処理をする必要がないので、if文で弾いている。
そして、肝心のニアレストネイバーはsampleUnit
で出力サイズに対しての入力サイズの比を求めて、マッピングするだけです。それ以外の処理は特に入れていないですが、この処理がほか2つの補間の前処理になっています。
Bilinear
バイリニア
このアルゴリズムは、出力テクスチャの座標を入力テクスチャの座標にマッピングして、出力テクスチャ座標の近傍4点の入力テクスチャ色の平均色を出力とするもの。
これも、最近傍点を基準に1ピクセルずらした位置のテクスチャ色を取得して平均を取ればいいだけなので簡単。
実際の実装は以下のようになっている。
// Bilinear
void BilinearInterpolation(uint2 id)
{
uint2 srcSize;
src.GetDimensions(srcSize.x, srcSize.y);
uint2 destSize;
dest.GetDimensions(destSize.x, destSize.y);
if(destSize.x <= id.x || destSize.y <= id.y)
return;
// ここから Bilinearの補間処理
float2 sampleUnit = float2(float(srcSize.x) / destSize.x, float(srcSize.y) / destSize.y);
float sx = 1.0;
float sy = 1.0;
float4 c0 = src[uint2(sampleUnit.x * id.x, sampleUnit.y * id.y)];
float4 c1 = src[min(uint2(sampleUnit.x * id.x + sx, sampleUnit.y * id.y), srcSize)];
float4 c2 = src[min(uint2(sampleUnit.x * id.x, sampleUnit.y * id.y + sy), srcSize)];
float4 c3 = src[min(uint2(sampleUnit.x * id.x + sx, sampleUnit.y * id.y + sy), srcSize)];
dest[id.xy] = (c0 + c1 + c2 + c3) / 4.0;
}
sampleUnit
までの処理はニアレストネイバーと同じです。あとは、ニアレストネイバーでサンプリングする座標と同じ座標を基準にして、sx
とsy
で補間対象の4点を決定。そして、その4点の平均を取って出力ピクセル色を決定している。
Bicubic
バイキュービック
このアルゴリズムは、上記二つのアルゴリズムと同様に近傍点を求め、さらにその点の近傍4x4の点をサンプリングして加重平均をとることで出力を決めるもの。
入力ピクセル座標とサンプリングした16点それぞれを距離$d$を使って加重平均の比率を決める。
weight = \begin{cases}
(a+2)d^3 - (a+3)d^2 + 1 \hspace{6mm} (0 \leq d < 1) \\
d^3 - 5ad^2 + 8ad - 4a \hspace{14mm} (1 \leq d < 2) \\
0 \hspace{59mm} (d \leq 2)
\end{cases}
この式において、$a$ はシャープ化を調整するパラメータとなっており、$-1.0 \leq a \leq -0.5$の範囲で値が小さくなるほどシャープ化が強くなる。
そして、以下のように出力のピクセル色を算出することで出力ピクセル色を決定する。
output = \dfrac{\sum_i^{16} input_{(i\bmod4,\hspace{2mm} \frac{i}{4})} \cdot weight_i}{\sum_i^{16} weight_i}
実装のイメージはだいたいこんな感じ。
実際の実装は以下のようになっている。
// Bicubic
void BicubicInterpolation(uint2 id)
{
uint2 srcSize;
src.GetDimensions(srcSize.x, srcSize.y);
uint2 destSize;
dest.GetDimensions(destSize.x, destSize.y);
if(destSize.x <= id.x || destSize.y <= id.y)
return;
// ここからBicubicの補間処理
float2 sampleUnit = float2(float(srcSize.x) / destSize.x, float(srcSize.y) / destSize.y);
float a = lerp(-1.0, -0.5, sharpness);
float4 color = float4(0.0, 0.0, 0.0, 0.0);
float weightSum = 0.0;
for(int sx = 0; sx < 4; sx++)
{
for(int sy = 0; sy < 4; sy++)
{
float d = sqrt((sx - 1.0) * (sx - 1.0) + (sy - 1.0) * (sy - 1.0));
float weight = d <= 1.0 ? (1.0 - (a + 3.0) * (d*d) + (a + 2.0) * (d*d*d)) :
d <= 2.0 ? (-4.0 * a + 8.0 * a * d - 5.0 * a * (d*d) + a * (d*d*d)) : 0.0;
color += src[min(uint2(sampleUnit.x * id.x + sx, sampleUnit.y * id.y + sy), srcSize)] * weight;
weightSum += weight;
}
}
dest[id.xy] = color / weightSum;
}
sampleUnit
までは上2つと同じである。まず、バイリニアと同様の方法で4x4の領域をサンプリングする。この際、バイリニアとは違って16点を参照するため、for文で記述している。
そして、d
の算出時、基準ピクセルを相対ピクセル座標系にて(-1, -1)として計算している。こうすることで、距離算出のときにバイキュービック法の意図しないweight
値の偏りが発生しないようにしている。
そうして算出したd
を使って、CPUから決定したa
と合わせてweight
を算出。color
にサンプリング点を足し合わせている。また、このときweight
も同様にweightSum
に足し合わせていき、最終的にcolor
をweightSum
で割ることで輝度の整合性を保つようにしている。
結果として得られた画像
結果、画像のリサイズ処理が完成し、実際に得られた画像がこれである。
こちらはUpdate関数内で画像のリサイズと閾値ありのピクセルソートを行っている。
サイズも自由に作れるため、かなり良い物ができた。ピクセルソートに関しては、画面全体に使うとかなり重く、しっかり画質を落とす必要がある。そのためリアルタイム用途なら、画面の一部にピクセルソートをかけるような特殊演出に使う分には十分実用的なものになった。とても嬉しい。