こんにちは!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
ゴール
画像が粗いですが、こんな感じでユーザーのタップ・スワイプに反応してぐにゃぐにゃする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座標
が更新されます。
setUpShaderView
でShaderView
に設定したonDrawFrameListener
で上記のパラメータをシェーダーに渡します。
まとめ
ShaderView
を使ってAndroidでシェーダーを使う方法を紹介しました。ShaderView
を使うと、OpenGL ESのややこしい設定をすることなくfragmentシェーダーで遊ぶことができます。
みなさんもShaderView
を使ってシェーダー芸人になりましょう!