12
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

ZOZOAdvent Calendar 2021

Day 21

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

Last updated at Posted at 2021-12-20

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

OpenGL ES実行環境の構築

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

ShaderView

そんなOpenGL ESの辛さを(一部)解決してくれるのが、appspell/ShaderViewです。これは、TextureViewをベースに作られたカスタムビューで、シェーダーを簡単に扱うことができます。
本記事では、このShaderViewを使ってモニョモニョしてみたいと思います。

Let's 実装

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

ゴール

output.gif

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

依存関係の追加

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

// 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はこんな感じです。

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
        }
    }
}

順に説明します。

画像の読み込み

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/

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

#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は以下のようになります。

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のドキュメントをご確認ください。

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

タッチイベントの制御

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

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座標

が更新されます。

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

まとめ

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

12
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?