Androidでもシェーダーでモニョモニョしたい！

こんにちは！tkhsktです。
昨今のオシャレWeb界隈ではWebGL(OpenGL)がたくさん使われていますが、AndroidやiOSではあまり話を聞かないな〜と思ったのでやってみました。

## OpenGL ES実行環境の構築

AndroidでOpenGL ESを使う場合、`GLSurfaceView`を利用することが多いと思います。が、`GLSurfaceView`では生に近いOpenGLの設定を書く必要があるため、OpenGLの知識がないとかなりキツいです。

### ShaderView

そんなOpenGL ESの辛さを(一部)解決してくれるのが、[appspell/ShaderView](https://github.com/appspell/ShaderView)です。これは、[TextureView](https://source.android.google.cn/devices/graphics/arch-tv?hl=ja)をベースに作られたカスタムビューで、シェーダーを簡単に扱うことができます。
本記事では、この`ShaderView`を使ってモニョモニョしてみたいと思います。

## Let's 実装

本記事で作成するサンプルプロジェクトはこちらです。
https://github.com/tkhskt/ShaderViewSample

### ゴール

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/155135/360f8a21-8d4c-3e11-0dfd-e74fa728e386.gif)

画像が粗いですが、こんな感じでユーザーのタップ・スワイプに反応してぐにゃぐにゃするViewを作ります。


### 依存関係の追加

ShaderViewを使用するモジュールに下記の依存を追加します。

```gradle
// ShaderView
implementation 'com.appspell:ShaderView:0.8.1'

// glide
implementation 'com.github.bumptech.glide:glide:4.12.0'
kapt 'com.github.bumptech.glide:compiler:4.12.0'
```

ここでは今回扱う`ShaderView`の依存と、ネットワークから画像を読み込むためglideの依存を追加しています。


### Activity/Fragmentの実装

Activityはこんな感じです。

```kotlin
class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    private var previousX: Float = 0f
    private var previousY: Float = 0f

    private var targetSpeed: Float = 0f
    private var followPointerX: Float = 0f
    private var followPointerY: Float = 0f

    @SuppressLint("ClickableViewAccessibility")
    private val touchListener = View.OnTouchListener { v, e ->
        val x: Float = e.x
        val y: Float = e.y

        when (e.action) {
            MotionEvent.ACTION_MOVE -> {
                val speed = sqrt(
                    (previousX - x).toDouble().pow(2.0) +
                            (previousY - y).toDouble().pow(2.0)
                )
                targetSpeed -= (0.1 * (targetSpeed - speed)).toFloat()
                followPointerX -= (0.1 * (followPointerX - x)).toFloat()
                followPointerY -= (0.1 * (followPointerY - y)).toFloat()
            }
            MotionEvent.ACTION_UP -> {
                targetSpeed = 0f
                followPointerX = 0f
                followPointerY = 0f
            }
        }

        previousX = x
        previousY = y

        return@OnTouchListener true
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)
        lifecycleScope.launch {
            loadImage()?.also { bitmap ->
                setUpShaderView(bitmap)
            }
        }
    }

    private suspend fun loadImage(): Bitmap? = suspendCoroutine { cont ->
        Glide.with(this)
            .asBitmap()
            .load("https://s3-ap-northeast-1.amazonaws.com/qiita-image-store/0/155135/0c2db45b0bd4b1aa023f5a7da835b76c2d191bd4/x_large.png?1585895165")
            .into(object : CustomTarget<Bitmap>() {
                override fun onLoadCleared(placeholder: Drawable?) {
                    // NOP
                }

                override fun onResourceReady(
                    resource: Bitmap,
                    transition: com.bumptech.glide.request.transition.Transition<in Bitmap>?
                ) {
                    cont.resume(resource)
                }

                override fun onLoadFailed(errorDrawable: Drawable?) {
                    cont.resume(null)
                }
            })
    }

    private fun setUpShaderView(bitmap: Bitmap) {
        val params = ShaderParamsBuilder()
            .addTexture2D(
                "uTexture",
                bitmap,
                GLES30.GL_TEXTURE0
            )
            .addVec2f("uPointer", floatArrayOf(followPointerX, followPointerY))
            .addFloat("uVelo", 0f)
            .build()
        val shaderView = ShaderView(this).apply {
            layoutParams = ViewGroup.LayoutParams(
                getScreenWidth(),
                getScreenWidth(),
            )
            updateContinuously = true
            fragmentShaderRawResId = R.raw.fragment
            shaderParams = params
            onDrawFrameListener = { shaderParams ->
                shaderParams.updateValue(
                    "uPointer",
                    floatArrayOf(followPointerX / 1000, followPointerY / 1000)
                )
                shaderParams.updateValue("uVelo", min(targetSpeed / 100, 0.5f))
                targetSpeed *= 0.999.toFloat()
            }
            setOnTouchListener(touchListener)
        }
        binding.root.addView(shaderView)
    }

    private fun getScreenWidth(): Int {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
            val windowMetrics = windowManager.currentWindowMetrics
            val insets = windowMetrics.windowInsets
                .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars())
            windowMetrics.bounds.width() - insets.left - insets.right
        } else {
            val displayMetrics = DisplayMetrics()
            windowManager.defaultDisplay.getMetrics(displayMetrics)
            displayMetrics.widthPixels
        }
    }
}
```

順に説明します。

#### 画像の読み込み

```kotlin
lifecycleScope.launch {
    loadImage()?.also { bitmap ->
        setUpShaderView(bitmap)
    }
}

...

private suspend fun loadImage(): Bitmap? = suspendCoroutine { cont ->
    Glide.with(this)
        .asBitmap()
        .load("https://s3-ap-northeast-1.amazonaws.com/qiita-image-store/0/155135/0c2db45b0bd4b1aa023f5a7da835b76c2d191bd4/x_large.png?1585895165")
        .into(object : CustomTarget<Bitmap>() {
            override fun onLoadCleared(placeholder: Drawable?) {
                // NOP
            }
            override fun onResourceReady(
                resource: Bitmap,
                transition: com.bumptech.glide.request.transition.Transition<in Bitmap>?
            ) {
                cont.resume(resource)
            }
            override fun onLoadFailed(errorDrawable: Drawable?) {
                cont.resume(null)
            }
        })
}
```

ここでは、Glideを使用して画像を読み込み、Bitmapを返しています。Bitmapの取得後は、以下の`setUpShaderView`にBitmapを渡し、画像を表示します。

#### シェーダーを書く

今回使用するfragmentシェーダーは以下のようになります。
こちらを参考にしました。
https://github.com/akella/webgl-mouseover-effects/

今回のサンプルでは画面や画像のアスペクト比などを考慮せずに実装していますが、さまざまな画像サイズに対応するためには座標の補正が必要です。

```glsl
#version 300 es

precision mediump float;

uniform sampler2D uTexture;
uniform vec2 uPointer;
uniform float uVelo;

in vec2 textureCoord;
out vec4 fragColor;

float circle(vec2 uv, vec2 disc_center, float disc_radius, float border_size) {
    uv -= disc_center;
    float dist = sqrt(dot(uv, uv));
    return smoothstep(disc_radius+border_size, disc_radius-border_size, dist);
}

void main()    {
    vec2 newUV = textureCoord;
    vec4 color = vec4(1., 0., 0., 1.);

    float c = circle(newUV, uPointer, 0.0, 0.8);
    float r = texture(uTexture, newUV.xy += c * (uVelo * .5)).x;
    float g = texture(uTexture, newUV.xy += c * (uVelo * .525)).y;
    float b = texture(uTexture, newUV.xy += c * (uVelo * .55)).z;
    color = vec4(r, g, b, 1.);

    fragColor = color;
}
```

このシェーダーに渡す`uPointer` `uVelo`を動的に変更することによって、画像をぐにゃぐにゃすることができます。

#### シェーダーに値を渡す

先ほど登場した`setUpShaderView`は以下のようになります。

```kotlin
private fun setUpShaderView(bitmap: Bitmap) {
    val params = ShaderParamsBuilder()
        .addTexture2D(
            "uTexture",
            bitmap,
            GLES30.GL_TEXTURE0
        )
        .addVec2f("uPointer", floatArrayOf(followPointerX, followPointerY))
        .addFloat("uVelo", 0f)
        .build()
    val shaderView = ShaderView(this).apply {
        layoutParams = ViewGroup.LayoutParams(
            getScreenWidth(),
            getScreenWidth(),
        )
        updateContinuously = true
        fragmentShaderRawResId = R.raw.fragment
        shaderParams = params
        onDrawFrameListener = { shaderParams ->
            shaderParams.updateValue(
                "uPointer",
                floatArrayOf(followPointerX / 1000, followPointerY / 1000)
            )
            shaderParams.updateValue("uVelo", min(targetSpeed / 100, 0.5f))
            targetSpeed *= 0.999.toFloat()
        }
        setOnTouchListener(touchListener)
    }
    binding.root.addView(shaderView)
}
```

まず、`ShaderParamsBuilder`を作成し、シェーダーに渡すパラメーターの初期値を設定します。

- `uTexture`
    - 表示する画像
- `uPointer`
    - タップしている座標
- `uVelo`
    - スワイプのベロシティ

次に、`ShaderView`のインスタンスを生成します。ここで設定しているパラメータは[ShaderViewのドキュメント](https://github.com/appspell/ShaderView/blob/main/README.md)をご確認ください。

最後に、Activityのビューに`ShaderView`を追加します。

#### タッチイベントの制御

ユーザーのタッチイベントを取得するため、`View.OnTouchListener`を定義し、`ShaderView`に設定します。

```kotlin
private var previousX: Float = 0f
private var previousY: Float = 0f
private var targetSpeed: Float = 0f
private var followPointerX: Float = 0f
private var followPointerY: Float = 0f

@SuppressLint("ClickableViewAccessibility")
private val touchListener = View.OnTouchListener { v, e ->
    val x: Float = e.x
    val y: Float = e.y
    when (e.action) {
        MotionEvent.ACTION_MOVE -> {
            val speed = sqrt(
                (previousX - x).toDouble().pow(2.0) +
                        (previousY - y).toDouble().pow(2.0)
            )
            targetSpeed -= (0.1 * (targetSpeed - speed)).toFloat()
            followPointerX -= (0.1 * (followPointerX - x)).toFloat()
            followPointerY -= (0.1 * (followPointerY - y)).toFloat()
        }
        MotionEvent.ACTION_UP -> {
            targetSpeed = 0f
            followPointerX = 0f
            followPointerY = 0f
        }
    }
    previousX = x
    previousY = y
    return@OnTouchListener true
}
...

private fun setUpShaderView(bitmap: Bitmap) {
    ...
    val shaderView = ShaderView(this).apply {
        ...
        onDrawFrameListener = { shaderParams ->
            shaderParams.updateValue(
                "uPointer",
                floatArrayOf(followPointerX / 1000, followPointerY / 1000)
            )
            shaderParams.updateValue("uVelo", min(targetSpeed / 100, 0.5f))
            targetSpeed *= 0.999.toFloat()
        }
        setOnTouchListener(touchListener)
    }
    ...
}
```

ユーザーがShaderViewをタップすると、

- `previousX` `previousY`
    - 直前にタップしたX,Y座標
- `targetSpeed`
    - `previousX` `previousY`を元に計算した、スワイプのベロシティ
- `followPointerX` `followPointerY`
    - シェーダーのエフェクトを発生させるX,Y座標

が更新されます。

`setUpShaderView`で`ShaderView`に設定した`onDrawFrameListener`で上記のパラメータをシェーダーに渡します。

## まとめ

`ShaderView`を使ってAndroidでシェーダーを使う方法を紹介しました。`ShaderView`を使うと、OpenGL ESのややこしい設定をすることなくfragmentシェーダーで遊ぶことができます。
みなさんも`ShaderView`を使ってシェーダー芸人になりましょう！