OpenGLでの動画フィルターを作成してみた

# はじめに

本田技研工業 RoadMovies+チームのテックリードを担当している松野と申します。
RoadMovies+の概要説明はTechブログ第二弾に記載していますので、ぜひご覧ください。

https://qiita.com/RintaroKo/items/4e1994efbd1d73c29236#roadmovies%E3%81%A8%E3%81%AF%E5%AE%A3%E4%BC%9D

今回はTechブログ第八弾の投稿となります。
RoadMovies+はiOS版が先行してリリースしており、後発のAndroid版では未実装の動画フィルターがあります。
今回はこれらのフィルターを作成した上で得た、Graphics API等に関する知見を共有したいと思います。

# TL;DR
- RoadMovies+のフィルターはiOSではMetal、AndroidではOpenGLを利用している
- レンダリングパイプラインを理解することで、フィルター作成の流れが理解できる
- フィルターをエディタ上で確認できるようにすることで、開発効率を向上した

# RoadMovies+のフィルターの内部実装について
RoadMovies+では動画のプレビューおよび書き出しを行うため、それぞれで動画に対して加工を行う必要があります。

iOSではプレビューは[AVPlayer](https://developer.apple.com/documentation/avfoundation/avplayer/)、書き出しは[AVAssetExportSession](https://developer.apple.com/documentation/avfoundation/avassetexportsession)を利用しています。
これらのClassでは[AVVideoComposition](https://developer.apple.com/documentation/avfoundation/avvideocomposition)を設定することで、各フレームでの加工方法を指定することができます。

Androidでは、プレビューは[ExoPlayer](https://developer.android.com/guide/topics/media/exoplayer)、書き出しは[MediaCodec](https://developer.android.com/reference/android/media/MediaCodec)を利用しています。
AndroidではiOSと異なり各フレームでの加工方法を直接設定することができません。
ExoPlayerではsetVideoSurface関数により動画をレンダリングする際のSurfaceを指定できます、 この際にSurfaceTextureをセットしたSurfaceを指定することでOpenGLによる加工を実現できます。
またMediaCodecでもデコードとエンコードはSurfaceを利用して実行されます。 そのため、デコードの出力SurfaceにはSurfaceTextureを指定した上でエンコードの入力Surfaceに対して描画を行うような実装にすることで、書き出しについてもOpenGLによる加工を実現できます。

以上の内部実装から、iOSでは[Metal](https://developer.apple.com/jp/metal/)、Androidでは[OpenGL ES](https://developer.android.com/guide/topics/graphics/opengl)を利用してフィルターを実現しています。

# Graphics APIについて
## Graphics APIとは?

ところで、急に[Metal](https://developer.apple.com/jp/metal/)や[OpenGL](https://www.opengl.org/)という単語が出てきましたが、これらは一体何者なんでしょうか。
これらは一般にGraphics APIと呼ばれるものになります。
最近では(3D)ゲームや機械学習の流行により、GPUの存在を多くの人が認知していることかと思います。
GPUは並列処理に大きな強みを持ち、画像処理、ビデオエンコード、3Dレンダリングなどの処理がCPUと比べて有利です。
これらの処理を行う際にGPU操作自体をプログラマーが行うのではなく、 抽象化されたインターフェースを介して実行します。
このインターフェースこそがGraphics APIになります。
また、OpenGL ESは組み込みやモバイルに特化したOpenGLのサブセットとなります。

## 各OSで対応しているでGraphics API
iOSでは元々[OpenGL ES](https://developer.apple.com/documentation/opengles)をサポートしていました。
しかし、[WWDC 2014でMetalを発表](https://developer.apple.com/videos/play/wwdc2014/603/)以降、 OpenGL ESは非推奨となりMetalが推奨APIとなりました。
また、WWDC 2017でMetal 2が発表、[WWDC 2022でMetal3 が発表](https://developer.apple.com/videos/play/wwdc2022/10066/)されるなど、APIのバージョンアップも行われています。
MetalおよびMetal 2はApple A7かつiOS 8以降の端末で、Metal 3はApple A13かつiOS 16以降の端末でサポートされています。

Androidでは主要なAPIとして[OpenGL ES](https://developer.android.com/guide/topics/graphics/opengl)を利用しています。
各バージョンのサポートOSは以下の通りです。
- OpenGL ES 1.0, 1.1: Android 1.0 以降 
- OpenGL ES 2.0: Android 2.2（API レベル 8）以降
- OpenGL ES 3.0: Android 4.3（API レベル 18）以降 
- OpenGL ES 3.1: Android 5.0（API レベル 21）以降

また、[Vulkan](https://source.android.com/docs/core/graphics/implement-vulkan?hl=ja#versions)のサポートもしている端末があります。
- Vulkan 1.0: Android 7（API レベル 24）以降 
- Vulkan 1.1: Android 9（API レベル 28）以降 
- Vulkan 1.3: Android 13（API レベル 33）以降

ただし、実際には対応しているバージョンは各端末に依るところが大きいです。
Android5.0以降の端末であってもOpenGL ES 2.0までしか対応していないといったことが起こり得ます。

# レンダリングパイプライン
Graphics APIで実際にフィルターを作成するには、まずレンダリングパイプラインの理解が必要になります。
レンダリングパイプラインは3D領域を描画可能な2D画像へ変換する処理のことを示します。
私が学習するうえで「特に参考になった」や「分かりやすかった」と感じたサイトを共有します。

https://www.docswell.com/s/ta_ka_tsu/NZWWJZ-2021-03-08-013242

https://hawk-tech-blog.com/python-opengl-pycg-lesson-1/

さて、動画フィルター自体は3Dではないにも関わらずなぜ重要かと言うと、実はフィルターの内部ではこのレンダリングパイプラインが動いているからです。
最終的には2D平面を描画したいだけなので、入力する頂点としては下のような(x, y, z)の頂点座標および、(u, v)のテクスチャ座標となります。

![頂点座標とテクスチャ座標.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079250/67759448-9272-35aa-155d-9b50a7d2239a.png)


後はVertex Shaderにおいて各頂点における(x, y, z, w)座標をvarying変数 vPositionとして、(u, v)座標をvarying変数 vTextureCoordとして代入すれば、Fragment Shaderにおいて各ピクセルの(x, y, z, w)座標および(u, v)座標にアクセスできるようになります[^1]。

[^1]: varying変数は各ピクセルが所属する頂点の値を補完した値が設定される。通常は線形補間となるが、補間方法を変更できるライブラリも存在する。

また、Vertex ShaderやFragment Shaderでは2D Textureをuniform変数として扱えます。
動画の特定フレームを画像として切り出し2D Textureとして取り込むことで、各ピクセルの色を取得することが可能になります。
例えばOpenGLではsampler2D関数を用いて、2D Textureの(u, v)座標の場所の色を取得することが可能です。
こうして得られた動画の各ピクセルの色を変更するようなロジックを実装することで、フィルターを実現することができます。

![OpenGLフィルターのフロー.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3079250/aba2118b-5b77-be25-2b15-a9e35973eff5.jpeg)


# フィルターを作成してみる
## 開発環境のセットアップ

1. VSCodeをインストール
まずはこちらからVSCodeをダウンロードします
https://code.visualstudio.com/

2. 拡張機能をインストール
以下の拡張機能をインストールします。
    - [glsl-canvas](https://marketplace.visualstudio.com/items?itemName=circledev.glsl-canvas)
    - [WebGL GLSL Editor](https://marketplace.visualstudio.com/items?itemName=raczzalan.webgl-glsl-editor)
    - (OPTIONAL) [Shader languages support for VS Code](https://marketplace.visualstudio.com/items?itemName=slevesque.shader) 
3. VSCodeの設定(settings.json)を追加

    
    ```json:settings.json
    {
      "glsl-canvas.refreshOnChange": false,
      "glsl-canvas.textures": {
          "0": "https://rawgit.com/actarian/plausible-brdf-shader/master/textures/noise/cloud-1.png",
          "1": "https://rawgit.com/actarian/plausible-brdf-shader/master/textures/noise/cloud-2.png",
      },
      "[glsl]": {
          "editor.defaultFormatter": "raczzalan.webgl-glsl-editor"
      }
    }
    ```
    
    `glsl-canvas.textures`に関してはuniform変数として外部からインポートしたい画像を指定します。
    ここではglsl-canvasのREADMEの画像を参照していますが、ここに関してはお使いになりたい画像を自由に設定可能です。
    
4. Macであれば`⌘ + ⇧ + P`でコマンドパレットを表示、「`Show glslCanvas`」を選択
表示された画面にフィルター結果が表示されます。
デフォルト設定で保存時自動更新がONに設定されているため、後はフィルターを実装したファイルを開くだけでフィルターの確認ができるようになります。

## 実際に作成してみる

こちらがフィルターのテンプレートになります。

```glsl:template.glsl
precision mediump float;

#if defined(VERTEX)

attribute vec4 a_position;
attribute vec2 a_texcoord;
varying vec4 vPosition;
// varying vec4 vNormal;
varying highp vec2 vTextureCoord;
// varying vec4 vColor;

void main(void) {
	vPosition = a_position;
	vTextureCoord = a_texcoord;

	gl_Position = vPosition;
}

#else

uniform vec2 u_resolution
uniform vec2 u_mouse;
uniform float u_time;
uniform sampler2D u_texture_0;
varying vec4 vPosition;
varying highp vec2 vTextureCoord;

void main() {
    gl_FragColor = texture2D(u_texture_0, vTextureCoord.xy);
}

#endif
```

[glsl-canvas](https://marketplace.visualstudio.com/items?itemName=circledev.glsl-canvas)が実行できるようにするために、1つのファイルにVertex ShaderとFragment Shaderを記述する必要があります。
そのため、[glsl-canvas](https://marketplace.visualstudio.com/items?itemName=circledev.glsl-canvas)で定義されているVERTEX定数を利用して、Vertex Shader評価時かFragment Shader評価時かを判別しています。

また、[glsl-canvas](https://marketplace.visualstudio.com/items?itemName=circledev.glsl-canvas)側でattribute変数として、

| Type | Property |
| --- | --- |
| vec4 | a_position |
| vec4 | a_normal |
| vec2 | a_texcoord |
| vec4 | a_color |

uniform変数として
| Type | Property |
| --- | --- |
| vec2 | u_resolution |
| float | u_time |
| vec2 | u_mouse |
| vec3 | u_camera |
| vec2[10] | u_trails[10] |

が使用できるほか、setting.jsonで設定した`glsl-canvas.textures`が
`u_texture_0`, `u_texture_1`, ...というuniform変数で使用できるようになります。

テンプレートでは、`glsl-canvas.textures`で設定したtexture[0]を表示するだけの関数を置いてあります。

実際にフィルターを作成する際には、
textureを受け取るフィルター関数を作成する方法が良いと思います。
一例として、画像を暗くするフィルターの実装例を示します。

```glsl:sample.glsl
precision mediump float;

#if defined(VERTEX)
attribute vec4 a_position;
attribute vec2 a_texcoord;
varying vec4 vPosition;
varying highp vec2 vTextureCoord;

void main(void) {
	vPosition = a_position;
	vTextureCoord = a_texcoord;
	gl_Position = vPosition;
}

#else

uniform sampler2D u_texture_0;
varying vec4 vPosition;
varying highp vec2 vTextureCoord;

vec4 extractColorFilter(
    vec2 vTextureCoord,
    lowp sampler2D sTexture
) {
    vec2 uv = vTextureCoord;
    vec3 color = texture2D(sTexture, uv).xyz;
    float minValue = min(min(color.x, color.y), color.z);
    return vec4(color - minValue, 1.0);
}

void main() {
    gl_FragColor = extractColorFilter(vTextureCoord.xy, u_texture_0);
}

#endif
```

# おわりに

以上のような実装でAndroid版のフィルターが実現されています。

実際には上の実装がそのまま用いられている訳ではありませんが、雰囲気が伝わって頂ければ幸いです。

現時点ではここ数ヶ月の成果としてできたフィルターたちはリリース版に反映できていないのですが、早いうちに皆様の手元へ配信できるよう邁進していきます。