[WebGL] レイマーチングでアンチエイリアス（FXAA）してみる

本記事は、[WebGLのAdventCalendar2018](https://qiita.com/advent-calendar/2018/webgl)の9日目の記事です。


## 概要

レイマーチング関連でなにか書きます、と書いちゃったんですがあまりレイマーチング関係ない話になってしまいました・・ｗ
いちおう、レイマーチングだと若干ジャギるので、それを軽減するためにアンチエイリアスを試してみよーっていう感じの記事です。

実際に適用したものの比較画像が以下です。
![cap.png](https://qiita-image-store.s3.amazonaws.com/0/5616/7f9bc138-a371-693a-939f-089f7b647398.png)

だいぶ縁のジャギーが目立たなくなっているかと思います。


今回作ったやつは以下にアップしました。Shadertoyで作ってます。

- [Raymarching with FXAA | Shadertoy BETA](https://www.shadertoy.com/view/MlGfDV)

今回の実装は、下で紹介するもののほぼコピーです；
時間がなくてあまりしっかり調べられず・・；
時間があったらしっかり書き直そうかなと思ってます。

ちなみに、FXAAを適用したやつは以前に記事を書いたものをベースに作っています。

- [レイマーチングでHeight Map Distance Field](https://qiita.com/edo_m18/items/af7fa86541634b59466f)



## FXAA

ちょっと古い記事ですが、今回はこれを参考に実装してみた感じです。

- [NVIDIA，独自のアンチエイリアシング技法「FXAA」「TXAA」をアピール。いまあらためて振り返るアンチエイリアシングの歴史](https://www.4gamer.net/games/120/G012093/20121125002/)

FXAAは「**Fast Approximate Anti-Aliasing**」の略です。
つまりは「すばやく近似するAA」ってことですかね。


### FXAAのアルゴリズム

該当の記事から引用させていただくと、以下のような特徴になります。

1. 通常のレンダリングを行う（サブピクセルは不要）
2. 周辺ピクセルとの輝度差を計算する
3. エッジの向きと長さを検出
4. エッジにあわせて周辺のピクセルと色をブレンド

また、アルゴリズムについての詳細は以下の資料にあります。

- [Filtering Approaches for 
Real-Time Anti-Aliasing](http://iryoku.com/aacourse/downloads/09-FXAA-3.11-in-15-Slides.pptx)

さて、実際にどんなことをやっているか、を少しだけ見ていきましょう。
まず、[最初に紹介した記事](https://www.4gamer.net/games/120/G012093/20121125002/)から画像を引用させていただくと、以下のように「エッジ」を検出します。

![画像のエッジを検出](https://www.4gamer.net/games/120/G012093/20121125002/SS/018.jpg)

引用画像のキャプションは以下のように書かれています。

>ピクセルの周囲との輝度の差をとってピクセルのエッジ（黒い線）を検出すると，オブジェクトの縁を推定できる（赤い線）。その推定された縁が横切る比率に基づいて周囲と色をブレンドしていくと，輪郭がぼかされ，ギザギザ感が目立たなくなる。これがFXAAの基本的な概念だ

つまり、赤い線の「方向」を求めることができれば、その方向に応じて周囲のテクセルのブレンド率を計算しアンチエイリアスを行うことができる、ということです。
ここで重要な点として、引用画像では周辺のテクセル数ピクセル分を表示していますが、実際には近傍の8ピクセルと自身のピクセルのみを用いて輝度の勾配を求め、そこからエッジの方向を求めることができます。

つまり、ポストプロセスとしては1passで行うことができる、ということを意味しています。
このあたりが処理負荷が軽いと言っている所以でしょう。

ちなみに「近傍のテクセル」の位置関係を表すと以下のようになります。

![](https://qiita-image-store.s3.amazonaws.com/0/5616/90477eca-6d9e-6b25-0e3e-81d444499074.jpeg)


#### エッジ以外では早期リターン

アルゴリズムでは、AAの計算をする必要があるかないかを、エッジになるかどうかをまず検知して、必要ない場合はそのまま該当テクセルを返すことで処理負荷を軽減しています。

資料では以下のように書かれています。

>Early exit for pixels not needing AA

（AAが必要ない場合は早期リターン）

判断の根拠としては以下のように書かれています。

>Need AA if contrast is high relative to maxLuma

つまり、コントラストが`maxLuma`に比べて高い場合はAAの計算が必要、ということです。

資料では疑似的に以下のコードとなっています。

```
maxLuma = max(nw,ne,sw,se)
contrast = max(nw,ne,sw,se,m) - min(nw,ne,sw,se,m)
if(contrast  >= max(minThreshold, maxLuma * threshold))
```

さて、AAの計算が必要と判断されたら、いよいよAAの計算を行います。



#### 輝度勾配を利用して方向を求める

資料には以下のように記述されています。

>Direction perpendicular to local luma gradient

輝度勾配に対して垂直なベクトルが方向、ということですね。

こちらも疑似コードで示されています。

```
dir.x = -((NW+NE)-(SW+SE))
dir.y = ((NW+SW)-(NE+SE))
dir.xy = normalize(dir.xy) * scale
```

資料からイメージ図を引用させていただくと以下のようになります。

![方向ベクトルのイメージ](https://qiita-image-store.s3.amazonaws.com/0/5616/21f373ff-8a71-a937-7086-83da5408036c.png)

計算式を見てもらうとなんとなくイメージできると思いますが、偏微分による法線を求めるような感じですね。
そしてそれに垂直になっているベクトルが求めたい方向ベクトルである、というわけです。

垂直なベクトルを求めるには、元のベクトルの`y`成分をマイナスしたものを垂直ベクトルの`x`成分に、元のベクトルの`x`成分を垂直ベクトルの`y`成分に割り当てることで求めることができます。

余談ですが、以前に「[偏微分（勾配）が法線を表すイメージ](https://qiita.com/edo_m18/items/3d95c2309d6ad5a6ba55)」という記事を書いているので、なぜ偏微分を計算することで法線が求まるのか、に疑問がある人は読んでみてください。（ただ、あくまで自分の理解、イメージをメモしたものです）


#### 方向から近傍テクセルのブレンド率を求める

さて、輝度による方向ベクトルの計算でエッジ具合が分かったので、これを利用して近傍テクセルのブレンド率を求め、最終的な描き込む色を決定します。
ただ実は、まだ自分で実装しておらず、ここに関しては理解が浅いので後日しっかりとした内容を書きたいと思います。

ということで、実際の実装についてはこちらを参考にさせていただきました。

- [(Tested) Fast Approximate Anti-Aliasing (FXAA) Demo (GLSL)](https://www.geeks3d.com/20110405/fxaa-fast-approximate-anti-aliasing-demo-glsl-opengl-test-radeon-geforce/3/)


ブレンド率を求めて、最終的な色を求めているコードを抜粋すると以下のようになっています。（少しだけ整形してます）

```glsl:最終ピクセルの色を計算する
vec3 rgbA = (1.0/2.0) * (
    FxaaTexLod0(tex, posPos.xy + dir * (1.0/3.0 - 0.5)).xyz +
    FxaaTexLod0(tex, posPos.xy + dir * (2.0/3.0 - 0.5)).xyz);
vec3 rgbB = rgbA * (1.0/2.0) + (1.0/4.0) * (
    FxaaTexLod0(tex, posPos.xy + dir * (0.0/3.0 - 0.5)).xyz +
    FxaaTexLod0(tex, posPos.xy + dir * (3.0/3.0 - 0.5)).xyz);

float lumaB = dot(rgbB, luma);

if((lumaB < lumaMin) || (lumaB > lumaMax))
{
    return rgbA;
}
else
{
    return rgbB;
}
```

ちょっとまだ、ここでなにを行っているのか理解しきれていませんが、前段で求めた方向ベクトルを利用して色を算出しているのが分かるかと思います。
そして輝度の最小／最大から外れているかどうかをチェックし、分岐して最終出力する色を決定しています。


#### 拡張機能を回避

ちなみに、参考にした上記コードそのままでは中で使用している関数（`texture2DLodOffset`）が拡張機能で使えなかったので、stackoverflowにあった同様の質問の回答を参考に少しだけ変更しています。

- [Invalid call of undeclared identifier texture2DLodOffset](https://stackoverflow.com/questions/29420332/invalid-call-of-undeclared-identifier-texture2dlodoffset)

回答のコード部分を引用させてもらうと以下のようになります。

```glsl
#if __VERSION_ >= 130
    #define OffsetVec(a, b) ivec2(a, b)
    #define FxaaTexOff(t, p, o, r) textureOffset(t, p, o)
#elif defined(GL_EXT_gpu_shader4)
    #define OffsetVec(a, b) ivec2(a, b)
    #define FxaaTexOff(t, p, o, r) texture2DLodOffset(t, p, 0.0, o)
#else
    #define OffsetVec(a, b) vec2(a, b)
    #define FxaaTexOff(t, p, o, r) texture2D(t, p + o * r)
#endif
```

そして今回の場合は一番下の

```glsl
#define FxaaTexOff(t, p, o, r) texture2D(t, p + o * r)
```

を、最終的にカスタマイズして使いました。
まぁやってることはシンプルに、オフセットを自分で計算しているだけですね。
拡張機能のほうは解像度を気にせずにコードが書けるのが楽、という感じでしょうか。
（逆を言えば、解像度を知っていれば自前でも計算できるってことですね）


## 余談

余談ですが、計算の中で輝度を求めている箇所があります。
具体的には以下ですね。

```glsl
vec3 luma = vec3(0.299, 0.587, 0.114);
float lumaNW = dot(rgbNW, luma);
```

`RGB`に、それぞれの要素ごとに比率を掛けて合計したものが輝度ですが、それに内積使ってます。
計算式自体はまったく同じになりますね。

なんとなく、輝度は「人間が認識する輝度方向への`RGB`ベクトルの類似度」なのかなーなんて思いました。（内積の意味的に）