Unityで画像のリサイズ with Compute Shader

## はじめに

最近、閾値ありのピクセルソートをリアルタイム実装したくて躍起になっていた。結局、閾値でせき止めを行う奇遇転置ソートでそこそこな速度の物ができた。
しかし、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点を抽出するだけなので簡単。

実装のイメージはこんな感じ。
![nearest_neighbor.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/242966/92f89763-22e6-abb5-2398-f6c84216034c.gif)

今回行った実装は、floorと同じ挙動でサンプリング点を決めている。これを、0.5を超えたらceilに変えて四捨五入にしてもよかったが、ピクセルパーフェクトを使う時に不整合が出ると嫌なので、とりあえずそのままにしている。

実装は以下のようになっている。

```hlsl:ResizeKernel.compute
// 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.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/242966/d133ba71-c8ee-a06b-96b3-e91690d1d997.gif)

実際の実装は以下のようになっている。

```hlsl:ResizeKernel.compute
// 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$を使って加重平均の比率を決める。

```math
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$の範囲で値が小さくなるほどシャープ化が強くなる。

そして、以下のように出力のピクセル色を算出することで出力ピクセル色を決定する。

```math
output = \dfrac{\sum_i^{16} input_{(i\bmod4,\hspace{2mm} \frac{i}{4})} \cdot weight_i}{\sum_i^{16} weight_i}
```

実装のイメージはだいたいこんな感じ。

![bicubic.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/242966/c53814f1-a2e6-8a64-b0fe-250167c3523d.gif)


実際の実装は以下のようになっている。

```hlsl:ResizeKernel.compute
// 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`で割ることで輝度の整合性を保つようにしている。

## 結果として得られた画像

結果、画像のリサイズ処理が完成し、実際に得られた画像がこれである。
![pixelsort.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/242966/a04207fe-e0f5-1549-0c93-f7e8303e96b0.png)


こちらはUpdate関数内で画像のリサイズと閾値ありのピクセルソートを行っている。
サイズも自由に作れるため、かなり良い物ができた。ピクセルソートに関しては、画面全体に使うとかなり重く、しっかり画質を落とす必要がある。そのためリアルタイム用途なら、画面の一部にピクセルソートをかけるような特殊演出に使う分には十分実用的なものになった。とても嬉しい。