Android Graphics Shading Languageって何?

Advent Calendar 2023, Android

この記事はレコチョク Advent Calendar 2023 の20日目の記事です。

はじめに

レコチョクでAndroidアプリ開発をしている寺島です。

アニメーションと一緒に聴く音楽が好きです。
手書きアニメーション風のMVを観るのが最近のマイブームです。

本記事では、Android Graphics Shading Language(以下、AGSLと略します)について記事を書きます。

Android OSは、年に1度バージョンが新しくなります。それに合わせてAndroidアプリの開発者は、targetSDKを最新にする対応をします。こちらの内容にあるように、アプリをリリースし続けるためにはAPI更新が開発者の義務になってます。

この対応の際には、Android 13で新しく提供されたAPIについても調査を行います。その時にこちらのページでAGSLについて知りました。

個人的に画像や画面の表示に関する技術に興味があり、AGSLを使うことでどのように画面描画ができるか気になっていました。そこでAdvent Calendar 2023では、AGSLを実際に試してみることにしました。

今回はAGSLをComposeで扱う場合の初歩的な内容をまとめました。

AGSLとは

Android グラフィック シェーディング言語(AGSL)は、Android 13 以降でプログラム可能なオブジェクトの動作を定義するために使用されます。AGSL は、その構文の多くを GLSL フラグメント シェーダーと共有しますが、Android グラフィック レンダリング システム内で機能し、 Canvas 内のペイントのカスタマイズと  View コンテンツのフィルタリングを行います。

参照

公式では上のように説明されています。AGSLはAndroid OSやアプリ上の描画処理をハードウェアレベルで実装できるものになります。実際にAGSLを使って、Android内部では波紋効果ぼかしストレッチオーバースクロールなどが実装されているそうです。

また、AGSLはOpenGL Shading Language(以下、GLSLと略します)をベースに作られています。GLSLは、ソフトウェアの開発者がハードウェアの描画処理を直接制御できるように策定された言語になっています。主に、リアルタイムな3DCGシーンの描画などに使用されます。

AGSLとGLSLの違い

AGSLはGLSLから派生した言語で、GLSLでできるような描画処理をAndroidでも使用できるように開発された言語です。そのため、AGSLとGLSLの違いは、Android上で動かせるようにした事による違いがほとんどと考えて良いようです。ここに書いた内容は、こちらを参考にしています。

また、2つの違いで最も注意すべき点は、画面座標系が異なるという事です。画面座標系が異なるというのは、簡単にいうと画面の左上が(0, 0)か画面の右上が(0, 0)になるかの違いです。以下の図のような違いになります。こちらから引用してます。

また、文法の違いもあります。文法については次の章で説明していきます。

AGSLの文法について

AGSLとGLSLの文法は、非常に似ています。そのため、GLSLで書かれたコードの多くは、最小限の変更でAGSLのコードとして使用する事が可能です。ただ、ベクトルや行列をサポートする型の中には、AGSLとGLSLで違うものもあります。

基本的な構文についても簡単に説明します。言語自体はC言語に似た書き方をする事ができます。 boolint などのプリミティブ型もC言語と同様の書き方をします。if-elseなど構文の書き方は、C言語に非常に似ています。switchやforなどのサポートもされているため、特別書きづらいコードではないです。

また、AGSLでは関数をサポートしていて、main関数から処理が始まります。加えて、ユーザーが関数を定義することが可能です。しかし、再帰的な関数を定義することはできないようです。

これらの説明はこちらを参考にしています。

次の章では、開発の準備について説明していきます。

開発準備

以下が開発環境になります。

  • IDE:Android Studio Giraffe (2022.3.1 Patch 4)
  • OS:Windows11
  • 使用言語:Kotlin
  • エミュレーターのバージョン:Android 13以上

AGSLを開発中に使えるようにするには、以下の準備が必要になります。

Android Studioが同じバージョンであれば、特別準備は必要ありません。Compose用プロジェクトを作成したら、準備完了になります!(今回は本筋ではないので、環境設定は細かく説明しません。)

⚠ Android 13のエミュレーターでないと、ランタイムエラーでアプリが落ちるので注意が必要です!

この後は、出来るだけ自分の思考や施行手順も再現しながら記事を書いていけたらと思います。
また、公式のAGSLのドキュメントを見ながら、自分で調査しつつ進めてます。

その1~画面を塗りつぶそう~

はじめは、ドキュメントの内容を上から読んでいきます。
ドキュメントには、以下を試すように書かれていました。

  • 単一の色を返す非常にシンプルなシェーダー
  • 赤色を使用している

最初は赤色を画面上に表示するサンプルだということがわかったので、実際にコードを見てみました。
以下がAGSLを記述したコードで、文字列内がAGSLになっているみたいです。

さらにドキュメント内のコードを見ていくと、

このように RuntimeShader というクラスのインスタンスを、作成しているみたいです。次のコードを見ます。

ここで少し気がついたのは、どうやらこのドキュメントは View でAGSLが動くようにするための文書らしいということです。上のコードをみると、 View で書かれている事がわかります。『Composeでプロジェクト作っちゃったよ…』というのが自分の心情でした。

とりあえず、ドキュメントにComposeで再現する方法がないかを探しました。すると下の方にComposeでの再現方法も書かれていました。したがって、とりあえずコードをそのまま書き写して、実行してみました。

すると、以下のような画面が表示されます。

これがComposeでのAGSLのHello Worldになるかと思います。とりあえず、動かせたのでComposeでドキュメントの最初からできるようにしてみます。

書いたコードを振り返ります。

これが自分の書いたコードです。一旦、AGSLの中身は見ずにComposeで何をやっているか考えました。

  1. RuntimeShaderを作る(ここでAGSLを使ってる)
  2. ShaderBlushを作る
  3. RuntimeShaderに値をセットする
  4. ShaderBlushをセットしつつ、Canvasで円を描く

これが大まかにしている処理かなと思いました。これを踏まえて、ドキュメントの最初のコードも振り返ります。振り返ると以下のように処理してました。

  1. RuntimeShaderを作る(ここでAGSLを使ってる)
  2. Paintを作る
  3. PaintにRuntimeShaderをセットする
  4. CanvasでPaintを描く

この2つを比較してみてみました。すると以下のことがわかりました。

  • RuntimeShaderはどちらにしても作る必要がある
    • このときにAGSLを必要とする
  • Canvasで描くものを決められる
  • 2 or 3で複雑なグラデーションを出す処理を書いている

とりあえずドキュメントの最初の状態にしたかったので、AGSLだけ最初のコードに切り替えることにしてみました。

クラッシュしたので、Logcatをみたところ、エラーは下のようになってました。

「使えないuniform namedがあるよ」との事です。上記のエラーは、 colorShader.setFloatUniform() の行を指していました。つまり、使えないUnifromを設定しようとしているため、エラーになっていると考えられます。よって、この処理を消してみることにしました。実行してみると、下のような円を描けました!『細かい内容は把握できてないが動いた!!やった!!』という気持ちです。

これでドキュメントの最初のサンプルが実装できました!赤色で実にシンプルな円を描けました。

ここまででわかったことは以下です。

  • colorShader.setFloatUniform() は、グラデーションに使う
  • AGSLの以下2つはグラデーションに使う
    • uniform float2 iResolution;
    • float2 scaled = fragCoord/iResolution.xy;
  • ComposeでAGSLを使って描くには、
    • RuntimeShaderを作る(AGSLを引数にする)
    • ShaderBlushを作る(RuntimeShaderを引数にする)
    • Canvasでdraw系の関数を使う
      • 関数を使う際にShaderBlushを引数に渡す

動いた時のAGSLの実装も見てみます。

ドキュメントには上記のコード上でどのように色を指定しているかは書かれていません。しかし、 half4(1,0,0,1) の部分を見ると、RGBAで赤を表していそうだなということがわかります。

つまり、この値を好きな色に変えて試してみると、実際にこのコードが色を表しているのかどうかわかるはずです。実際に試してみました。

half4(1, 0, 1, 1) おそらく紫になるであろうと予測しました。実行すると、以下のようになります。( drawCircle()drawRect() に変えてます。)

予測通りの色が出ました!さらに最後の引数がアルファ値か確認するため、以下を試しました。

  • half4(0, 0, 0, 1);
  • half4(0, 0, 0, 0.6);

アルファ値が1の場合

アルファ値が0.6の場合

これで先程の half4 がRGBAを表していることがわかりました。
ここまでで、その1を終了しようと思います。

その2~KotlinからAGSLへの色渡し~

その1とその2の間に調べたことがあります。以下が調べた内容です。

  • half4型について
    • half4の4は4次元を意味している
    • floatだと思って良さそう(正確には精度が異なるらしい)
  • AGSLのmain()の引数について
    • 使ってなさそうと思って消して実行したがエラー出た
      • 必ずvec2, float2を引数として使えとエラー

そこから次に、以下のAGSLを試しました。

とりあえず、AGSLだけを入れ替えて動かしてみました。すると以下のような画面が出ます。(真っ白な画面が出ました。わかりづらいです。)

特にエラーもなしに、上の画面が表示されるようになりました。いまいち何が良くないのかわからないです。そこでドキュメントを読み進めると、Kotlin側で色の受け渡しをする必要があるとわかりました。そこで書いたコードが下になります。

実行してみると、以下の画像が表示できました。

つまり、 setColorUniform()layout(color) uniform half4 iColor;

に値を渡すことができるということが何となくわかりました。ここで疑問になったことを調べました。

  • layout(color) uniform half4 iColor;layout(color) は何を指しているか?
    • つけていると android.graphics.Color で色を渡せる
    • つけなくても渡せるが4つの数値を渡す必要がある
      • colorShader.setFloatUniform("iColor", 1f, 0f, 1f, 1f) みたいに書く

ここでは以下を学びました。

  • Kotlin側からAGSLに値を渡す方法
    • serColorUniform(), setFloatUniform()

その3~グラデーションを描画しよう~

次は、グラデーションについてやっていきます。最初に出力したグラデーションがどのように出力されるのかを考えました。

AGSLのコードは以下です。

まず、 uniform float2 iResolution; に値を渡しています。さらに scaled という変数を作成して、その変数に手を加えたものを結果として渡しています。

ここで main() の引数について調べる必要がありそうです。

ドキュメントには

ピクセルごとに、X座標とY座標を解像度で割った

と書いてあります。割り算をしている箇所は1箇所しか無いので、 scaled に引用の内容で行った事の結果を代入している事がわかります。つまり、以下のことがわかります。

  • fragCoord は各ピクセルの座標を表している
  • iResolution は画面の解像度を表している
    • 2次元変数になるので、おそらく画面のWidthとHeight
  • scaled は、2次元の変数であること

ここでとりあえず出力をしてみることにしました。実際に出力時のコードが以下になります。

上のコードで若干詰まったのは、解像度の取得です。Composeではどのように解像度を取得するのかがわからず、つまりました。Canvasのブロック内であれば、Windowのサイズを取得できます。

Canvasのブロック内はdrawScopeになっているため、描画に必要な画面の情報についてもアクセスすることが出来ます。

結果、下のような画面を出力することが出来ました。

出力を見た上でAGSLを確認すると、気になることが出てきました。

  • これまでは単色だったのでreturnする値と出力される色がイコールだった
    • 例えば、 half4(1, 0, 0, 1) = 赤
    • 例えば、赤を渡した iColor = 赤
  • 今回は1つの結果しか返していないのに、複数色を出力している
    • 画面の位置によって違う色を出力している

ここでまた、出力の結果をいじってみます。scaledの値は scaled.x で1つ目の値を参照できて、 scaled.y で2つ目の変数を参照できます。また、 half4(scaled, 0, 1) と書いた時に、 scaled の中身が (0, 1) であれば、 half4(0, 1, 0, 1) と同等になるみたいです。

half4(scaled, 0, 1)の場合

half4(0, scaled, 1)の場合

half4(scaled.x, 0, scaled.y, 1)の場合

こんな感じになります。この画像を見ると、以下の特徴がわかります。

  • 左上はどの画像も黒っぽい
  • 右上と左下はRGBの原色っぽい
  • 右下はRGBのうち、2色を使ってできた色っぽい
    • 黄色、水色、紫
    • たぶん、イエロー、シアン、マゼンタになってる

1枚目の画像の最上部の色の変化を考えます。

青枠に囲まれた部分は、黒から赤に変わっているように思います。つまりこの部分の色は、 half4(0, 0, 0, 1)half4(1, 0, 0, 1) に変化しているとわかります。改めて、AGSLでreturnしている値を見てみます。

scaled の2次元変数のうち、1つ目は0→1になっていて、2つ目は0→0となることがわかります。

つまり、 fragCoord.x / iResolution.x = 0fragCoord.x / iResolution.x = 1 と変わっていくことがわかる。 iResolution は、定数になるはずなので、 fragCoord.x の値が変わっていることがわかる。式の状態から考えると、 fragCoord.x は0→ iResultion.x のように値が変わっていっていることになる。

よって、 fragCoord は、画面の位置を表していそうだとわかりました。

上記のAGSLのコードは以下をやっていそうです。

  • iResolution 画面の幅を定義する
  • 描画するピクセルのX・Y座標を受け取る
  • scaled.xscaled.y に0~1の小数を代入する
    • このとき代入される値はピクセルの位置ごとに違う
  • 返り値として色を渡す

上記の流れで、グラデーションを画面に出力していることがわかった。

その3で学んだことは以下になります。

  • fragCoord は画面の座標を表している
  • half4(scaled, 0, 1) のような4次元変数に2次元変数を渡すようにコードを書ける

その4~アニメーションさせる~

ドキュメントでは、ついに描画した内容をアニメーションさせるとのことが書いてあります。さっそくAGSLをみていきます。

また、いくつか変数が増えてきました。

  • iResolution さっきまでどおりならば、画面の解像度
  • iTime アニメーションさせるので変化していく時間っぽい
  • iDuration durationなのでアニメーションの区切りまでの秒数っぽい

scaled を求める計算式があるので、今回はこれが出力の絵に関係してきます。

とりあえず、コードを動かせるようにしてみます。このとき、ドキュメントだけではわからなかったので、他の方の記事を参考にしました。以下が参考にした記事です。

記事ではViewで作っていたので、自分はComposeで動かせるようにしていきました。

最終的に出来たコードが以下になります。

ここまではあまり考え事はせずに調べたコードを真似て、色々書いてみました。ビルドしながらドキュメントで表示されているアニメーションのようになるまでを繰り返した感じです。(あまり賢いやり方ではないかもです。しらみつぶしに動くまで試した感じです。考えなしで突き進みました。)

結果、出力は以下のようになりました。

Videotogif (2).gif

ドキュメント通りの見た目になっているかと思います。ここで動かすまでに詰まったポイントを書いていきます。

  • 経過時間ごとに値が変わる時間変数 iTime に経過時間を渡す方法
    • 今回は withInfiniteAnimationFrameMillis を使った
    • これによって延々と変わる時刻を取得
      • この方法だとある時刻からの経過時間をミリ秒で取れる
  • 画面を描画したは良いが、アニメーションが適用されない
    • 今回は Modifier.drawWithContentのブロック内で描画処理を記述することでアニメーションをさせることが出来た
      • Modifier.drawWithContent は、 ModifierCanvas の描画処理を記述できる
      • ここに書いた描画処理は再コンポジション時に毎回描画されるためアニメーションさせることができる
      • Modifier内で描画するため、特別Composable関数の方で Canvas を使う必要はない

次にアニメーションを描画するための式を見ていきます。

1つずつ変数を見ていきます。

  • fragCoor : 画面の座標
  • iResulution : 画面の解像度
  • iTime : 経過時間
    • 実際に出力される値:86223743
    • この値は1ミリ秒で1増える
  • iDuration : 繰り返し間隔の秒数

ここでX座標: 0、Y座標: 0のピクセルが時間経過でどのようになるかを考えます。
この時、 iTime = 86223743 / 1000 , iDuration = 4 となります。
これをもとに下のコードだとどのようになるか、X座標のみで計算してみました。

計算した結果、 scaled = 0.8715 となります。

scaledの1つ目の値は0.8715になりますが、代入されるfragCoordの値が等しいので、2つ目の値も0.8715になります。つまり、scaledは (0.8715, 0.8715) が結果となります。

また、上記の計算をiTimeが変わるごとに計算すると、値が0~1の間で変わるようになっています。これによって、(0, 0)の座標にあるピクセルは描画が時間で変わってくようになってます。また、scaledの1つ目の値と2つ目の値はどちらも必ず同じ値になります。

さらに、この値がRGBの赤と緑に使われるので、(0, 0)のピクセルは黒色から黄色を繰り返し描画する状態になることがわかります。動画を見ても(0, 0)のピクセルは黒色から黄色を繰り返しています。

根気強く計算すると、各ピクセルの描画の様相もわかりそうですが、時間がかかるため今回はそこまではやりませんでした。

その4ではAGSLを使ったアニメーションの方法を学びました。

その5~アニメーションを文字に反映させる~

ここではComposeで作った表示内容にAGSLの描画内容を反映させるということをやっていきます。

今回参考になったのは以下の動画です。

調べた内容に動く状態までにしてコードが以下になります。

ここまで実装してからビルドすると以下のような結果を表示することが出来ました。

ここからは参照したYouTubeの動画で何をしていたのかを自分なりに説明していきます。

実際に文字アニメーションを反映されるために追加した処理は以下です。

  • TextonSizeChanged でAGSLで使用する値を渡す処理をする
  • TextgraphicsLayer で以下の処理をする
    • AGSLで使用する値を渡す処理をする
    • Text からピクセルのアルファ値をAGSLに渡す

まず、 onSizeChanged について説明します。この中の処理は Text の大きさが決まった時&サイズが変わる時に行われます。そのため、大きさに関する値をここでAGSLに渡しています。

次に、 graphicsLayer について説明します。この中の処理は描画したComposableに変換を適用することが出来ます。たとえば、拡大・縮小などです。今回の場合は時間という変化を使って、Composableに変換を行いたかったためにこの修飾子を使ってます。また、ここでは Text のピクセルが持つ描画情報をAGSLに渡し、AGSL側で使用することができるようにしています。

上記の createRuntimeShaderEffect(shader, “composable”) で、AGSLに Text の描画情報を渡しています。AGSLでは composable 変数として使うことができるようになり、実際に composable.eval(fragCoord).a で描画するピクセルのアルファ値が Text だとどの様になっているかを取得しています。

図で説明すると以下の感じになります。

説明図

テキストで文字が書かれているところだけは、アルファ値が1になるという情報を composable.eval(fragCoord).a で取得してます。文字以外のところはアルファ値が0になるため、描画されない状態になります。したがって、アニメーションしている描画を文字に反映させることができるようになります。

その5ではアルファ値をうまく使って、ComposableにAGSLで描いた結果を反映させる方法を学びました。ドキュメントでは文字の回転も行っていたのですが、 graphicsLayer を使うと簡単に実装できそうなので、今回はノータッチとしています。

まとめ

Android13のtargetSdkを更新する作業の時に気になった内容を、今回は深掘りして調べてみました。個人的には以下を学ぶことが出来たのでやってみてよかったなと思いました。

自分が学んだ事

  • Composeの描画周りの簡単な知識
    • drawWithContentonSizeChanged などの細かなComponentの制御
  • Androidの画面描画の仕組み
  • Composeでデフォルトで用意されているようなComponentのアニメーションがどう実装されるのか
  • AGSLで学んだこと
    • ピクセル毎に色を出力する方法( fragCoord から描画中のピクセルを判断できる)
    • AGSLによるアニメーションの実現(時間経過の変数を取り入れることで時間経過で出力する色を変える事ができる)
    • 各ComposableへのAGSLの反映方法

AGSLの強み

また、AGSL自体が何に利用できるかも自分なりにまとめてみました。

AGSLは以下の点に強みがあります。

  • ハードウェアの描画処理に直接アクセスできるため、高速に処理を行える
    • ピクセル単位での描画を高速に行える
  • 時間経過による描画の処理に強いため、UIのアニメーション実装に活かせそう

Composeはまだライブラリとしては、普及してから日が浅いです。
そのため、ライブラリとして用意されていない描画周りの処理もあるかと思います。
そのような場合はAGSLを使用して、自分なりにカスタムしてUI周りの処理を行うのも可能そうです。

結構長い記事になってしまいましたが、ここまで読んでいただきありがとうございます。

参考文献

GitHub

また今回使用したプロジェクトは下記からアクセスが可能です。参考にしてみてください!
こちら

明日はレコチョクのAdvent Calendar 2023の21日目の記事で、「【Fusion360】 Fusion360で3Dプリンタ印刷用のパーツをモデリングしてみよう!!」が投稿されます。お楽しみに!

この記事を書いた人

寺島広
寺島広
レコチョクの寺島です。
Android アプリ開発に携わっています。
アニメ、ゲームなどが好きです。