アプリの基礎

Jetpack Compose と Android の最新のグラフィック機能でアプリを強化する

スピーカー

  • Rebecca Franks

    デベロッパー リレーション エンジニア

  • Aayush Chaudhary

    ソフトウェア エンジニア

  • Rahul Ray

    デベロッパー リレーション エンジニア

概要

講演について

Android UI を「機能する」から「芸術作品」に引き上げる準備はできていますか?

Jetpack Compose の最新のグラフィック機能をご紹介します。楽しいメッシュ グラデーションからプログレッシブ ブラーまで、Android の強力な新しいグラフィック機能を使用して新しい視覚効果を構築する方法をご紹介します。また、バッテリー駆動時間やフレームレートを犠牲にすることなく、グラフィック機能のパフォーマンスを向上させる方法についても説明します。

概要

  • Compose 1.12 では、頂点の 2D グリッド全体で豊かなマルチカラー ブレンドを実現するメッシュ グラデーションが導入されています。Android 14 以降では、ネイティブの drawMesh API を使用して、カスタムのジオメトリ駆動型視覚効果を構築できます。
  • Compose は広色域をサポートしており、対応するハードウェアでより滑らかなグラデーション ブレンドを実現します。アクティビティまたはウィンドウで広色域モードを有効にするのは、リッチメディアを表示する場合のみにしてください。
  • Compose 1.13 では、カスタム ジオメトリ用のネイティブ PolygonShape API と MorphPolygonShape API が導入されています。MorphPolygonShape は、アウトライン解決フェーズ中に進行状況を読み取り、再コンポーズをトリガーすることなく、フレームごとにシェイプを更新します。
  • Compose 1.13 ではプログレッシブ ブラーが追加され、API レベル 37.2 では背景コンテンツをサンプリングするための RenderNode.setBackdropRenderEffect が導入されています。デベロッパーは、背景効果を連結して、すりガラス効果や拡大効果を作成できます。
  • フレーム間で不要な処理を回避して、グラフィックのパフォーマンスを最適化します。メインスレッド外で必要な画像ピクセルのみをデコードし、アニメーション効果を graphicsLayer でラップします。

リソース

Gemini Notebook

トピック

  • jetpack-compose
  • android-graphics
  • mesh-gradients
  • シェーダー
  • ぼかし
  • color-gamut
  • パフォーマンス

文字起こし

Jetpack Compose のグラフィック機能の概要

  • 皆さん、こんにちは。このセッションでは、Compose と Android の最新のグラフィック機能を使用してアプリを強化する方法について説明します。

  • Rebecca Franks です。本日は、

  • Aayush Chaudhary と申します。

  • こんにちは。Rahul Ray です。

  • Android と Compose で可能なことの限界を押し広げ、AI を活用して素晴らしいアイデアを形にするという、新たなトレンドが生まれています。

  • また、プレミアム感があり、ネイティブで、楽しいアプリも高く評価されます。

  • ユーザーに好まれ、一貫して高い評価を得ているアプリは、一般的に、最新のシステムレベルの API 変更を活用し、共有要素とぼかしによってサーフェス間の継続性を保ち、フォーム ファクタ全体で適切に適応し、パフォーマンスが高く、ユーザーを迷わせず、デバイスで可能なことの限界を押し広げています。

  • このセッションでは、Android を活用するのに役立つ最新機能の一部をご紹介します。メッシュ グラデーションによるユーザー エクスペリエンスの向上、広色域のサポート、多角形による魅力的な遷移、ぼかしの新機能、そして最後に、アプリに新しいグラフィック機能を追加する際のパフォーマンスの考慮方法について説明します。

  • 本日の講演では、スタックのさまざまなレベルの新しい API について説明します。

  • 従来のビュー UI ツールキットは現在メンテナンス モードになっていますが、Jetpack Compose を支える基盤となる Android グラフィック ハードウェア レンダリング エンジンへの投資は継続しています。

  • Android のグラフィックは 3 つのレイヤに分かれていると考えることができます。第 1 レイヤは Compose API で、メッシュ グラデーション ペインター、ポリゴン シェイプ、プログレッシブ ブラー API など、高レベルの新しい UI グラフィック API はすべてここにあります。

  • そして中間レイヤでは、Compose の DrawScope、描画修飾子ノード、AGSL を使用してカスタム エフェクトを構築できます。

  • 最後に、Android プラットフォームでは、Compose は常にネイティブの Canvas にアクセスし、Compose 修飾子内で最新の OS レンダリング プリミティブを使用できます。

  • 本日は、その例をいくつかご紹介します。

  • 始めましょう

メッシュ グラデーションとカスタム メッシュ レンダリング

  • まず、Compose 1.12 で導入された新しい API であるメッシュ グラデーションについて見ていきましょう。

  • 以前は、線形、スイープ、円形のグラデーションがあり、これらはシンプルな遷移に適していましたが、アンビエント背景やフラッドライト効果のような豊かな多色ブレンドが必要な場合は、位置と色が空間全体でブレンドできる 2D グリッドのシステムが必要でした。

  • このようなメッシュ グラデーションを作成するには、まず、パッチの行数と列数を指定します。

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
    }
    }
  • 次に、末尾のラムダ内で setVertex を呼び出し、0F ~ 1F の範囲の正規化されたオフセットと、各グリッド交点のカラーを割り当てます。

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
       setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
    
    }
    }
  • そして、それらの頂点をアニメーション化するために、8 つの頂点にアニメーション化されたオフセットを追加します。

    val gradientPainter = remember {
    MeshGradientPainter(rows = 3, columns = 3) {
       setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
         // ....
       setVertex(1, 1, Offset(0.2f, 0.4f) + Offset(animatedOffset, animatedOffset), coral)
       // ....
       setVertex(3, 0, Offset(0.0f, 1.0f), sunshine)
       // ....
    }
    }
    
    Box(
       modifier = modifier.padding(32.dp)
           .safeContentPadding()
           .aspectRatio(16 / 9f)
           .fillMaxWidth()
           .paint(gradientPainter)
    )
  • ペインターを定義したら、ペイント修飾子を介して任意のコンポーザブルで呼び出すだけです。

  • JetChat アプリに、メッセージをダブルタップすると「いいね」を付けられる機能を追加しました。

  • これを作成するには、2x3 のメッシュを設定し、コンポーネント全体にカラーストップを配置します。

    MeshGradientPainter(
       rows = 2,
       columns = 3,
       hasBicubicColor = true,
    ) {
       // Row 0 (top edge, y = 0.0f)
       setVertex(0, 0, Offset(0.0000f, 0.0000f), Color(0xFFFF6B6B))
       setVertex(0, 1, Offset(0.3300f, 0.0000f), Color(0xFFFFD6D6))
       setVertex(0, 2, Offset(0.6700f, 0.0000f), Color(0xFFE8C3FF))
       setVertex(0, 3, Offset(1.0000f, 0.0000f), Color(0xFFE396FF))
    
       // ... remaining vertices for Rows 0, 1, and 2
    }
  • ここでは、hasBicubicColor を true に設定しています。

  • デフォルトでは、色は三角形全体に線形に補間されます。これにより、目に見えるパッチが残ることがあります。

  • これをオンにすると、シェーダーが滑らかなバイキュービック補間に切り替わり、色がシームレスに溶け合うようになります。

  • では、入力ボックスの背後にこのようなアニメーション化されたアンビエント グローを構築するにはどうすればよいでしょうか。

  • メッシュ グラデーションでこのような効果を実現するには、次の 2 つが必要です。

  • まず、内側の頂点に滑らかな有機的な動きを加え、外側のエッジを柔らかくして、カードの周りに硬い長方形ができないようにします。

  • まず、2 つの位相角を持つ rememberInfiniteTransition を設定します。この 2 つの位相角は、機械的ではなく有機的な動きになるように、わずかに異なる時間で動作します。

    val transition = rememberInfiniteTransition(label = "userInputGlowMesh")
    
    val phase1 by transition.animateFloat(
       initialValue = 0f,
       targetValue = (2 * PI).toFloat(),
       animationSpec = infiniteRepeatable(
           animation = tween(durationMillis = 2800, easing = LinearEasing),
           repeatMode = RepeatMode.Restart,
       ),
       label = "glowPhase1",
    )
    
    val phase2 by transition.animateFloat(
       initialValue = 0f,
       targetValue = (2 * PI).toFloat(),
       animationSpec = infiniteRepeatable(
           animation = tween(durationMillis = 4200, easing = LinearEasing),
           repeatMode = RepeatMode.Restart,
       ),
       label = "glowPhase2",
    )
  • ここでは、内側の頂点をアニメーション化し、外側のリング、つまりアルファ値が 0 のエッジを持つ頂点の外側のリングを固定します。

  • 外側の境界は静止していて透明であるため、グローは自然にフェードアウトします。

  • 最後に、それらのアニメーション オフセットを MeshGradientPainter にフィードします。

    MeshGradientPainter(
       rows = 6,
       columns = 5,
       hasBicubicColor = true,
    ) {
       for (r in 0..6) {
           for (c in 0..5) {
                   val offset = if (r in 1..5 && c in 1..4) {
                   val ampX = when (r) {
                       1, 2 -> 0.048f
                       3 -> 0.042f
                       4 -> 0.028f
                       else -> 0.036f
                   }
                   val ampY = when (r) {
                       1, 2 -> 0.028f
                       3 -> 0.020f
                       4 -> 0.022f
                       else -> 0.016f
                   }
                   val d = orbit(c, r, ampX, ampY)
                   Offset(
                       x = (baseU + d.x).coerceIn(0.04f, 0.96f),
                       y = (baseV + d.y).coerceIn(0.05f, 0.95f),
                   )
               } else {
                   Offset(baseU, baseV)
               }
               setVertex(r, c, offset, gridColors[r][c])
           }
       }
    }
  • 先ほど見たメッシュ グラデーションは、Android 14 以降の drawMesh API を使用して描画されています。

  • この API を詳しく見ていくために、エフェクトをゼロから作成してみましょう。

  • JetChat のウェルカム画面では、リボンのようなパスを描画します。

  • ここで、2 つの点に注目してください。線の幅が一定ではないことと、パスの各線に沿って色が変化していることです。

  • このエフェクトを構築するために実際に必要なものを詳しく見ていきましょう。

  • 各ピクセルについて、描画するかどうか、どの色を使用するかを決定する必要があります。

  • これを把握するには、2 つの距離が必要です。

  • 最初の値は、ピクセルがストロークの中心からどれだけ離れているかを示します。

  • 2 つ目の距離は、パスの始点から終点までのピクセルの距離です。

  • これにより、グラデーションからどの色を選択するかが決まります。最初の値を少し混ぜることで、幅全体にわたって色が変化します。

  • では、パスとペイントだけを使用して、パスに沿ってグラデーションを描画できるでしょうか?

  • はい、できます。

  • そのためには、パスを短い直線のリストにフラット化する必要があります。これにより、パスに沿った距離も得られます。

    val lines = remember {
            path.asAndroidPath().flatten(0.5f)
        }
    //... inside draw scope
    lines.forEach { line ->
    //...
        drawLine(
            brush = Brush.linearGradient(...),
            start = Offset(line.start.x,
    line.start.y),
            end = Offset(line.end.x, line.end.y),
            strokeWidth = 30f,
        )
    //...
    }
  • 次に、各線を独自のグラデーションで描画します。

  • この方法でも描画できますが、各線に個別の描画呼び出しと Paint オブジェクトが必要であり、パス全体の幅は 1 つのままです。

  • もう 1 つのオプションはランタイム シェーダーですが、これはコンテナ境界内のピクセルの位置のみを取得します。

  • このリボンを描くために必要なものはわかりません。

  • また、ピクセルの位置から簡単に計算することもできません。

  • DrawMesh は、そのための頂点シェーダーを提供します。

  • では、メッシュとは何でしょうか。

  • メッシュは、各コーナーにデータを持つ三角形で構成され、各コーナーは頂点と呼ばれます。

  • 各頂点には、小さなデータレコードが含まれます。

  • 通常、そのレコードはポジションを保持しますが、必ずしもそうである必要はありません。

  • 頂点シェーダーはそのレコードを読み取り、そこから位置を計算します。

  • 1 つの三角形がたどるパスは次のとおりです。頂点シェーダーが各コーナーで実行され、コーナーの位置と、各ピクセルに渡す値が計算されます。

  • 次に、ラスタライザがその三角形で覆われたすべてのピクセルを検出し、各ピクセルについて 3 つの角から値を補間します。

  • 最後に、それらのピクセルに対してフラグメント シェーダーが実行され、補間された値を使用してピクセルの色が計算されます。

  • 頂点シェーダーとフラグメント シェーダーの記述方法を、よりシンプルな例で見てみましょう。直線に沿ってパルスが移動する例です。

    LineShaders.VERTEX
    
    uniform float2 uStart;
    uniform float2 uEnd;
    uniform float uHalfWidth;
    uniform float uTime;
    
    Varyings main(const Attributes a) {
        Varyings v;
        float2 dir = uEnd - uStart;
        float2 normal = normalize(float2(-dir.y, dir.x));
        float along = a.corner.x * length(dir);
        float pulse = (a.corner.x - uTime) / 0.18;
        float radius = uHalfWidth * (1.0 + 0.6 * exp(-pulse * pulse));
        float across = a.corner.y * (radius + 1.0);
        v.position = uStart + dir * a.corner.x + normal * across;
        v.stroke = float3(along, across, radius);
        return v;
    }
  • 頂点シェーダーは、属性を入力として受け取り、varying を返します。

  • 属性は、1 つのコーナーのデータです。

  • この 2 つの数値は、線に沿った進行状況と、線のどちら側にあるかを示しています。

  • その後、varying が補間され、フラグメント シェーダーに渡されます。

  • 開始点からのピクセル距離を計算し、ストローク幅には時間を使用して半径にパルスを作成します。

  • 次に、その半径に角の辺を掛けると、ストローク全体の符号付き距離が得られます。

  • GPU がこれらの符号を補間すると、中心線はゼロになります。

  • 最後に、角の位置を計算し、これらすべての値をストロークの varying に格納して、フラグメント シェーダーに渡します。

  • ランタイム シェーダーとは異なり、メッシュ フラグメント シェーダーはパラメータを介して色を出力し、ペイントをサンプリングするための位置を返します(設定されている場合)。

    LineShaders.FRAGMENT
    
    uniform float uLength;
    layout(color) uniform float4 uStartColor;
    layout(color) uniform float4 uEndColor;
    
    float2 main(const Varyings v, out float4 color) {
        float distance = abs(v.stroke.y);
        float coverage = 1.0 - smoothstep(
        v.stroke.z - 0.5, v.stroke.z + 0.5, distance);
        float side = distance / v.stroke.z;
        float4 ink = mix(uStartColor, uEndColor,
            clamp(v.stroke.x / uLength + 0.4 * side * side, 0.0, 1.0));
        color = ink * coverage;
        return v.position;
    }
  • 横座標の絶対値を取ると、中心線からのピクセル単位の距離が得られます。

  • この距離を半径と比較してカバレッジを取得し、色については、線のパスに沿ってブレンドし、グラデーションをエッジに向かって少し曲げます。

  • そのカバレッジをカバレッジで乗算すると、最終的な色になります。

  • Compose 側では、シェーダーとデータ レイアウトを MeshSpecification オブジェクトに結び付けます。

    val spec = remember {
        MeshSpecification.make(
            arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
            vertexStride = 8,
            arrayOf(Varying(TYPE_FLOAT3, "stroke")),
            LineShaders.VERTEX, LineShaders.FRAGMENT)
    }
  • まず、corner 属性をバイト 0 から始まる 2 つの浮動小数点値として宣言します。

  • 2 つの float は 8 バイトを占めるため、バッファ内の各コーナーのストライドは 8 です。

  • 次に、ストロークのバリアントを 3 つの浮動小数点数として宣言し、先ほど作成した頂点シェーダーとフラグメント シェーダーを渡します。

  • 頂点バッファを作成します。

    fun lineCorners(segments: Int): ByteBuffer {
        val buffer = ByteBuffer.allocateDirect((segments + 1) * 2 * 8)
            .order(ByteOrder.nativeOrder())
        for (i in 0..segments) {
            val t = i / segments.toFloat()
            buffer.putFloat(t).putFloat(-1f) // one side
            buffer.putFloat(t).putFloat(1f)  // the other side
        }
        return buffer.rewind() as ByteBuffer
    }
  • 線に沿った各ステップで、進行状況を完了し、0 から 1 までの進行状況を計算して、2 つの角をバッファに書き込みます。

  • 最後に、仕様とバッファをメッシュ オブジェクトに結合します。

    val mesh = Mesh(
        spec,
        mode = Mesh.TRIANGLE_STRIP,
        vertexBuffer = corners,
        vertexCount = (segments + 1) * 2,
        bounds = RectF(0f, 0f, size.width, size.height)
    )
  • Compose キャンバスはまだ drawMesh を公開していないため、nativeCanvas にドロップダウンしてメッシュを描画します。

    Box(Modifier.fillMaxSize().drawWithCache {
       val mesh = Mesh(...)
       onDrawBehind {
           mesh.setFloatUniform("uTime", time.value)
           drawIntoCanvas {
              it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
           }
       }
    })
  • ジオメトリとピクセルを制御できるため、ドローメッシュを使用して、ジオメトリ駆動の視覚効果を描画できます。

  • この API の詳細については、ドキュメントをご覧ください。

広色域のサポート

  • Compose 1.12 では、広色域のサポートが導入されました。

  • では、これは具体的にどういうことでしょうか。

  • 実は、画面は従来の sRGB 色空間とは異なる範囲の色を再現できることをご存じでしょうか。

  • たとえば、この画像を見ると、白い三角形はほとんどの画面で表示できる標準の sRGB カラーの範囲を示しています。

  • しかし、新しい画面の中には、黄色の三角形で示されているように、より幅広い色をサポートしているものもあり、より彩度が高く鮮やかな色を表現できます。

  • しかし、色を表現する場合、実際にはその三角形の各頂点の説明で表現します。

  • このグラデーションの比較を見る際は、ディスプレイの色域とグラデーションの補間を区別することが重要です。

  • 標準の sRGB ディスプレイやこのプロジェクターでは、この sRGB 色域外の色を物理的にレンダリングすることはできません。

  • そのため、この場でデモをお見せするのは非常に困難です。

  • 広色域をサポートする Android では、色がより鮮やかに見えることを信じてください。

  • この例で実際に確認できるのは、より広い色空間でカラー分岐点を補間する効果です。

  • 広色域またはリニア色空間で色をブレンドすると、このグラデーションの実際の数学的パスが変更され、広色域を使用しない場合に発生する可能性のある中間グレー化や、不自然なバンディングを防ぐことができます。

  • Compose 1.12 では、どのディスプレイでもグラデーションのブレンドがより滑らかになるほか、対応するハードウェアで実行している場合は、真の広色域がサポートされます。

  • デバイスで適切なサポートを有効にするには、アクティビティまたはウィンドウが広色域モードを有効にするように設定されていることを確認する必要があります。

  • これは AndroidManifest で行えます。

    <!-- AndroidManifest.xml -->
    <activity
    android:name=".NavActivity"
    android:colorMode="wideColorGamut"
    android:exported="true">
    </activity>
  • または、コンポーザブルでプログラムによって設定します。

    // Dynamic toggle in code
    DisposableEffect(window) {
       window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
       onDispose {
           window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
       }
    }
  • colorMode を設定しない場合、ウィンドウは標準の 8 ビット sRGB のままになり、GPU によってレンダリングされた広色域の色はクリップされます。

  • そのため、ディスプレイが実際にサポートしていない場合は、広色域の色を使用しないことが重要です。

  • バッテリーとパフォーマンスを節約するため、実際にメディア、写真、リッチ グラフィックを表示するアクティビティまたはコードパスでのみ、広色域を有効にする必要があります。

  • つまり、Compose でさまざまな色空間の色を作成できるようになり、それらが尊重されるようになりました。

    // 1. Display P3 Color (Wider gamut: greens & reds are more saturated) colorMode=wideColorGamut
    val VividP3Red = Color(
       red = 1.0f,
       green = 0.05f,
       blue = 0.05f,
       alpha = 1.0f,
       colorSpace = ColorSpaces.DisplayP3
    )
    // 2. Extended sRGB (Enables HDR values > 1.0f) use colorMode=hdr
    val HdrNeonCyan = Color(
       red = 0.0f,
       green = 1.8f,  // Peak brightness > 1.0 in extended gamut
       blue = 2.0f,
       alpha = 1.0f,
       colorSpace = ColorSpaces.ExtendedSrgb
    )
    // 3. BT.2020 HLG (Broadcast HDR standard, supported API 34+) - use colorMode=hdr
    val HdrSunsetOrange = Color(
       red = 1.0f,
       green = 0.45f,
       blue = 0.1f,
       alpha = 1.0f,
       colorSpace = ColorSpaces.Bt2020Hlg
    )
  • 作成する色の colorSpace のタイプを渡すことができます。colorSpace を渡さない場合は、標準の sRGB が使用されます。

  • 特にビットマップについては、getColorSpace を使用してビットマップのソースを確認できます。一般的な画像読み込みライブラリは、正しいビットマップ色空間を自動的に処理します。

  • 色情報を保持するには、Bitmap.Config.HARDWARE を使用する必要があります。565 などの構成では、実際には精度が失われ、ユーザーにバンディングが見えるようになります。

  • 色について詳しくは、2017 年に Romain Guy が Compose の色や Android の色について語った動画をご覧ください。

ポリゴン形状とモーフィング

  • Compose 1.13 では、ポリゴン形状のネイティブ サポートが導入されています。

  • これにより、カスタム シェイプや有機的な外観の UI 要素を簡単に作成できるようになります。

  • 以前は、このような複雑なジオメトリを描画するには、RoundedPolygon を使用してパスを出力するジオメトリを記述し、そのパスを Compose Shape API にマッピングする必要がありました。

    fun cookiePolygon(): RoundedPolygon {
        val vertices = FloatArray(32)
        for (i in 0 until 16) {
            val radius = if (i % 2 == 0) 1f else 0.84f
    // …
        }
        return RoundedPolygon(
            vertices,
            rounding = CornerRounding(0.12f)
        )
    }
  • たとえば、まず RoundedPolygon を使用して比率を定義し、このスニペットでは、内側の点を外側の点と比較して中心から 84% の位置に設定します。

  • ここで角を丸くすることもできますが、これは数学的な曲線を表しているだけです。

  • この多角形を Compose でクリッピングや背景に実際に使用するには、このようなカスタム Shape ラッパーを作成する必要があります。

    class RoundedPolygonShape(
     polygon: RoundedPolygonShape
    ) : Shape {
        // …
        override fun createOutline(size: Size, layoutDirection: LayoutDirection,
        density: Density,
        ): Outline {
            path = polygon.toPath().asComposePath()
            // …
            matrix.scale(size.width / maxDimension, size.height / maxDimension)
            matrix.translate(-bounds.left, -bounds.top)
            // …
            return Outline.Generic(path)
        }
    }
  • この createOutline スニペット内では、パスを取得して、手動でスケーリングと変換を行う必要があります。

  • 新しい PolygonShape API を使用すると、カスタムの Shape ラッパーは不要になります。

    private val AvatarShape = PolygonShape {
        val vertices = List(16) { i ->
            val radius = size.minDimension / 2f * if (i % 2 == 0) 1f else 0.86f
            val angle = i * PI.toFloat() / 8f
            size.center + Offset(cos(angle), sin(angle)) * radius
        }
        polygon(vertices, rounding = CornerRounding(radius = 8.dp))
    }
    
        // …
    Modifier.clip(AvatarShape)
  • これは専用の Compose Shape ビルダーであるため、密度とサイズを直接受け取ります。

  • つまり、頂点を動的に計算し、角の丸みを dp またはパーセンテージで直接指定できます。

  • 定義したら、Shape を受け取る API に渡すだけです。

  • また、MorphPolygonShape API も導入されます。これにより、異なるポリゴン形状間をシームレスに移行できるようになります。

    val recordShape = remember {
        MorphPolygonShape(
            start = PolygonShape.circle(),
            end = PolygonShape.star(
                numPoints = 9,
                innerRadiusRatio = 0.8f,
                outerRounding = CornerRounding(percent = 50),
            ),
        ) { shapeProgress.value }
    }
  • この例では、操作すると円から星に変化する録画ボタンがあります。

  • 開始シェイプ、終了シェイプ、進行状況の値を指定するだけです。

  • MorphPolygonShape API は、アウトライン解決フェーズ中に進行状況の値を読み取ります。つまり、コンポーズ中ではなく、

  • つまり、フレーム間でシェイプがモーフィングされると、再コンポーズをトリガーすることなく、新しいアウトラインに解決されます。

ぼかしと背景のレンダリング効果

  • 次に、ぼかしについて説明します。

  • 過去数回のリリースで、ぼかしの実装をより簡単かつ表現力豊かにすることに取り組んできました。

  • ぼかしについて説明する際、この修飾子がよく使用されます。

    Modifier
       .blur(
           radiusX = 10.dp,
           radiusY = 10.dp,
           edgeTreatment = BlurredEdgeTreatment(
               shape = RoundedCornerShape(8.dp),
           ),
       )
  • Compose には、Android 12 以降、この修飾子が用意されています。

  • コンポーザブル全体にまったく同じぼかし半径を適用する、均一なぼかしが必要な場合に最適です。

  • しかし、最新の UI デザインでは、はっきりした境界ではなく、鮮明な領域からぼやけた領域への滑らかな遷移が求められることがよくあります。

  • そこで登場するのが、Compose 1.13 から正式な API となったプログレッシブ ブラーです。

  • コンポーザブル全体で一定の半径を使用するのではなく、空間全体でぼかしの強度を変化させることができるようになりました。

  • これは、JetChat アプリのユーザー プロフィール画面の例です。

  • 新しい blur 修飾子は、ぼかし半径の仕様を受け取ります。

    Modifier.blur {
    blurRadiusSpec = BlurRadiusSpec.verticalGradient(
    listOf(
    BlurStop(0.5f, 0.dp),
    BlurStop(1.0f, 32.dp),
    )
    )
    edgeTreatment = BlurredEdgeTreatment.Unbounded
    }
  • ここでは、2 つのぼかしストップを持つ垂直方向のグラデーションを使用し、写真の上半分を 0 dp で完全にシャープに保ち、下端に向かって 32 dp のぼかしをかけています。

  • また、edgeTreatment を unbounded に設定しています。これにより、コンポーザブルの境界外の透明なピクセルもサンプリングするようにぼかしに指示しています。

  • このぼかし半径の仕様では、縦方向のフェードアウト以外にも、プログレッシブ ぼかしを適用する組み込みの方法がいくつか用意されています。

  • たとえば、左側には水平方向のグラデーションが表示されています。開始半径は 0 dp で、終了半径はスライダーの値によって決まります。

    BlurRadiusSpec.horizontalGradient(
    startRadius = 0.dp,
    endRadius = sliderValue.dp,
    )
  • 右側には円形グラデーションがあります。中心ではぼかし半径が 0 dp で、fallOffRadius(この場合は 300 dp)に向かって移動するにつれて、ぼかしが 24 dp まで滑らかに増加します。

    BlurRadiusSpec.radialGradient(
    center = center,
    fallOffRadius = 300.dp,
    startRadius = 0.dp,
    endRadius = 24.dp,
    )
  • これらの垂直、放射状、水平のグラデーションだけでは不十分な場合は、カスタム シェーダーを使用してプログレッシブ ブラーを適用することもできます。

    val customShader = remember {
       RuntimeShader(
           """
           uniform float2 size;
           uniform float time;
    
           half4 main(float2 coords) {
              // Diagonal wave mask
              float normalizedX = coords.x / size.x;
              float normalizedY = coords.y / size.y;
              float wave = sin((normalizedX + normalizedY) * 6.0 + time) * 0.5 + 0.5;
              return half4(0.0, 0.0, 0.0, wave);
           }
           """.trimIndent()
       )
    }
    
    val customBlurRadius = remember(customShader) {
       BlurRadiusSpec.shader(maxRadius = 24.dp) { customShader }
    }
    
    Modifier.blur {
           val w = size.width.toPx()
           val h = size.height.toPx()
           customShader.setFloatUniform("size", w, h)
           customShader.setFloatUniform("time", animatedTime)
           blurRadiusSpec = customBlurRadius
       },
  • ここでは、アニメーション化された対角線の正弦波を計算し、その強度をアルファ チャンネルに出力する小さなフラグメント シェーダーを作成します。

  • 次に、シェーダー仕様が 0 ~ 1 のマスクを 24 dp のマスク maxRadius に直接マッピングします。

  • 背景のぼかしについて説明する前に、この 2 つの違いを見てみましょう。

  • 左側には、レイヤぼかしを適用したトップ アプリバーがあります。

  • つまり、コンポーザブル自体をぼかします。

  • ご覧のとおり、タイトルとアクション アイコンはぼやけていますが、その背後にあるチャット コンポーネントはまったくぼやけていません。

  • 右側は背景のぼかしです。

  • ここでは、上部バーの後ろでスクロールするチャット メッセージのみがサンプリングされ、ぼかされています。

  • ただし、テキストとアイコンは完全に鮮明で読みやすいです。

  • Android 37.2 以降では、RenderNode.setBackdropRenderEffect という新しい API を使用して背景をぼかすことができます。

  • ぼかしの改善方法について説明する前に、Android の内部でぼかしのパフォーマンスを確保する仕組みを理解しておきましょう。

  • Android は内部で RenderNode を使用します。これはハードウェア アクセラレーション対応の表示リスト コンテナです。つまり、記録の開始と終了により、さまざまな描画コマンドを圧縮して保存できます。

  • RenderNode には、3D 変換、回転、アルファ、スケール、位置境界などの情報が保持されます。

  • Compose は、実際には内部で RenderNode を使用しています。

  • そのため、Compose でバックドロップ レンダリング効果を使用するには、この修飾子を再利用できるようにカスタム Modifier.Node を作成します。

    fun Modifier.backdropBlur(spec: BlurRadiusSpec, shape: Shape, tint: Color): Modifier =
       this then BackdropBlurElement(spec, shape, tint)
    
    private class BackdropBlurNode(...) : Modifier.Node(), DrawModifierNode {
       private var renderNode: RenderNode? = null
    
       override fun ContentDrawScope.draw() {
           val node = getOrCreateNode()
           if (isDirty || sizeChanged) {
               node.setPosition(0, 0, widthPx, heightPx)
               node.setBackdropRenderEffect(spec.createRenderEffect(this))
           }
           drawIntoCanvas { it.nativeCanvas.drawRenderNode(node) }
           drawContent()
       }
    }
  • RenderNode への参照を保持し、RenderNode は別の関数で必要なエフェクトを使用して構成されます。

  • たとえば、ここではぼかし効果を渡します。

  • 次に、drawIntoCanvas を呼び出し、ネイティブ キャンバスで drawRenderNode を呼び出します。

  • 最後に、drawContent を呼び出して、ぼかしのない実際のコンテンツを上部に描画します。

  • 作成したこの修飾子を使用するには、Modifier.backdropBlur を呼び出し、ぼかし半径の仕様を渡します。

    JetchatAppBar(
        modifier = modifier
            .backdropBlur(
                tint = MaterialTheme.colorScheme
                    .surface.copy(alpha = 0.5f),
                shape = RectangleShape,
                spec = BlurRadiusSpec.uniform(5.dp)
            )
    )
  • このアプローチでは、基盤となるレイヤに何も配置する必要はありません。

  • RenderNode.setBackdropRenderEffect は、JetChat アプリバーの表面の下にあるコンテンツを自動的にサンプリングしています。

  • その結果、背景が美しくぼかされます。

  • また、プログレッシブだけでなく、プログレッシブぼかし半径の仕様を設定して、コンテンツに垂直方向のぼかしを適用することもできます。

    JetchatAppBar(
        modifier = modifier
            .backdropBlur(
                tint = MaterialTheme.colorScheme
                    .surface.copy(alpha = 0.5f),
                shape = RectangleShape,
                spec = BlurRadiusSpec.verticalGradient(
                    listOf(
                        BlurStop(0.5f, 32.dp),
                        BlurStop(1f, 0.dp),
                    ),
                )
            )
    )
  • 垂直方向のグラデーションを使用すると、コンテナのさまざまな部分で異なる半径でぼかされたコンテンツが表示されます。

  • Compose に組み込まれた背景ぼかし修飾子を開発中です。

  • これは、Compose を使用した Android の基盤となるテクノロジーの一例にすぎません。

  • この例は API レベル 37.2 でのみ動作しますが、Compose のバックポートも開発中ですので、ご期待ください。

  • RenderNode 背景効果のもう 1 つの面白い例は、実際にそれらを連結して、非常に見栄えの良い UI を形成し、その上で複数の異なる効果を実行できることです。

  • エフェクトを連結するには、これら 2 つのエフェクトを作成します。

    val blur = RenderEffect.createBlurEffect(24f, 24f, Shader.TileMode.CLAMP)
    
    // Boost saturation by 30% and apply blur together
    val matrix = ColorMatrix().apply { setSaturation(1.3f) }
    val vibrancy = RenderEffect.createColorFilterEffect(ColorMatrixColorFilter(matrix))
    val vibrantBlur = RenderEffect.createChainEffect(vibrancy, blur)
  • この例では、ぼかし効果と 30% の彩度を作成します。

  • createChainEffect を呼び出し、最初にぼかし効果を適用し、次に彩度を適用します。

  • これは JetChat の例です。

    val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
    val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
    
    val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
    renderNode?.setBackdropRenderEffect(finalEffect)
  • ジャンプボタン。ジャンプボタンは、いくつかの異なるエフェクトを組み合わせて、このコンポーザブルの端を虫眼鏡のように丸くすることで、小さな虫眼鏡のように見せることができます。

  • 拡大効果を適用できるだけでなく、あらゆる種類のシェーダーをチェーン化して背景に適用できます。

  • これらを組み合わせることで実現できる、楽しい例をいくつかご紹介します。

  • 従来のすりガラスのような効果を実現できるようになりました。

  • 万華鏡のような楽しいエフェクトも作成できます。

  • また、この Android マスコットを、下にあるコンテンツを変換するモーフィング UI 要素のようにしたい場合は、これらのバックドロップ レンダリング効果を適用して、この小さなドロイドを通して見ているように見せることもできます。

  • これは、背景の RenderEffects でも行うことができます。

  • この新しい API で背景のぼかしとすりガラス効果が利用できるようになっただけでなく、想像力次第で何でもできるようになりました。

  • プラットフォームに導入されたもう 1 つの新しい API は SurfaceView.setBlurRegions です。

  • 現在、テクスチャ ビューをぼかすことができ、Modifier.blur と RenderNode のバックドロップ効果を使用して任意のコンポーザブルをぼかすことができますが、SurfaceView の一部をぼかすのは難しく、実際にはウィンドウに穴を開けるため、アプリのビュー RenderNode ツリーの一部ではありません。

  • そのため、Compose は SurfaceView のバッキング バッファを実際にサンプリングできず、API 37.2 までは実現できませんでした。

  • そのため、SurfaceView の setBlurRegions を使用すると、アプリでぼかし領域を直接宣言できます。

  • たとえば、JetChat のこの動画プレーヤーでは、コントロールの表示と非表示の切り替え時に SurfaceView のぼかしを追加しました。

  • この新しい API を使用するには、SurfaceView メソッドで setBlurRegions にアクセスします。

    val region = RoundedRectBlurRegion().apply {
       bounds = RectF(left, top, right, bottom)
       setCornerRadii(spec.cornerRadiusPx.coerceAtLeast(0f))
       alpha = spec.alpha.coerceIn(0f, 1f)
       blurRadius = spec.blurRadiusPx.coerceAtLeast(0f)
    }
    
    surfaceView.setBlurRegions(listOf(region))
    
    surfaceView.setBlurRegions(emptyList()) // Clear
  • この関数は、境界の領域のリスト、ぼかし半径、アルファ、角丸半径のリストを受け取ります。

  • これらの境界を渡すときは、画面上の SurfaceView を基準にする必要があります。

  • そのため、SurfaceView の座標とぼかしを適用する領域を追跡する必要があります。

RuntimeColorFilter

  • Android 16 以降のもう 1 つの新機能は RuntimeColorFilter です。

  • 以前は、画像に対して設定できるのは静的なマトリックス カラーフィルタのみで、彩度などのエフェクトを適用していました。

  • しかし、Android 16 以降では、入力色(その特定のコンテンツの入力ピクセル)を受け取り、新しい色を生成するシェーダーを渡すことができるようになりました。

  • たとえば、写真にサイバーパンク フィルタを適用する場合は、RuntimeColorFilter への入力として使用される小さなシェーダーを定義します。

    @Language("AGSL")
    private const val CYBERPUNK_SHADER = """
        vec4 main(half4 in_color) {
            float lum = dot(in_color.rgb, vec3(0.299, 0.587, 0.114));
            vec3 shadow = vec3(0.06, 0.04, 0.25);
            vec3 mid = vec3(0.92, 0.12, 0.58);
            vec3 high = vec3(0.18, 0.94, 0.98);
            vec3 col = lum < 0.5 ? mix(shadow, mid, lum * 2.0) : mix(mid, high, (lum - 0.5) * 2.0);
            return vec4(mix(in_color.rgb, col, 0.7), in_color.a);
        }
    """
  • このシェーダーはサイバーパンク テーマを生成しますが、必要に応じて他のさまざまなエフェクトを適用できます。

  • シェーダー自体を使用するには、RuntimeColorFilter を適用して Compose カラーフィルタに変換します。

    AsyncImage(
        model = imageUri,
        contentDescription = filter.displayName,
        colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                       .asComposeColorFilter(),
        contentScale = ContentScale.Crop,
        modifier = Modifier.fillMaxSize(),
    )
  • この例からわかるように、OpenGL や SurfaceView の設定などを詳しく知らなくても、以前よりも動的なエフェクトを作成できるようになりました。

  • これは画像だけでなく、任意のコンポーザブルに適用できます。

グラフィック パフォーマンスの最適化

  • ありがとうございました

  • これで、非常に優れたエフェクトを構築できましたが、アプリが高速かつスムーズに動作することを確認しましょう。

  • 会話をスクロールすると、すべてのエフェクトが同時に実行されます。

  • まず、このスクロールを測定してみましょう。

    @Test
    fun scrollFling() = measureScroll(swipeSteps = 5)
    
    private fun measureScroll(swipeSteps: Int) = rule.measureRepeated(
        packageName = TARGET_PACKAGE,
        metrics = defaultComposeScrollingMetrics(),
        compilationMode = CompilationMode.Full(),
        iterations = 5,
        setupBlock = {
            killProcess()
        }
    //...
  • 新しいプロセスでアプリを起動し、会話をスクロールするマクロ ベンチマークを使用します。

  • 次に、トレースをクエリして、期限に間に合わなかったフレームをカウントします。

    select count(*) as late_frames
    from actual_frame_timeline_slice
    where upid = (select upid from process where name = 'com.example.compose.jetchat.perf')
      and jank_type like '%App Deadline Missed%'
  • これにより、6 つの遅延フレームが発生します。

  • 遅延フレームの 1 つを開いてみましょう。

  • このフレームの直前に、Compose は次のメッセージがビューにスクロールされる前にプリフェッチしています。

  • プリフェッチはフレーム間の短いギャップに収まるはずですが、このメッセージを準備すると、その画像もメインスレッドでデコードされ、中断することなく長時間実行されます。

  • メインスレッドがこの画像のデコードで停止しているため、新しいフレームを開始できません。

  • その後、そのメッセージが実際にビューにスクロールされると、RenderThread はメモリを割り当てて、それらのピクセルをすべて GPU にアップロードする必要があります。そのため、まったく同じ画像が 2 番目のフレームを遅延させます。

  • これで、時間がどこに費やされたかがわかりました。しかし、このフレームを描画するために、これらすべての作業が必要なのでしょうか。

  • どうやって判断できるでしょうか。

  • トレースが教えてくれます。

  • 大きなビットマップが画面上の小さな領域に描画されるたびに、Android はトレースにマーカーを記録します。

  • ビットマップ全体のサイズと、画面上で実際にカバーしている領域の 2 つの数値がピクセル単位で表示されます。

  • それを画面密度で割ると、DP 単位の値が得られます。

  • 次に、その DP 値を使用してコードを検索し、その画像を見つけます。

  • その結果、次のことがわかりました。

  • 画像は painterResource で読み込まれます。はコンテナのサイズを認識しないため、画像全体をフル解像度でデコードします。

    Image(
        painter = painterResource(it),
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • そのため、coil の AsyncImage に置き換えます。

    AsyncImage(
        model = it,
        contentScale = ContentScale.Fit,
        modifier = Modifier.size(160.dp),
        contentDescription = stringResource(id = R.string.attached_image),
    )
  • 最初にコンテナを測定するため、必要なピクセルのみをデコードし、その処理を別のスレッドで行います。

  • 同じベンチマークを再度実行すると、メインスレッドでの画像デコードは 0 に、遅延フレームは 2 に減少しました。

  • ここでは、この 2 つのフレームのデバッグは行いませんが、重要なポイントはシンプルです。フレームに到達しないピクセルは計算しないようにしてください。

  • 画像のデコードはフレームの遅延を引き起こすほど大きなコストですが、すべてのコストがフレーム落ちとして現れるわけではありません。コードを記述する際に回避できる小さなコストもあります。

  • そのうちの 2 つを見てみましょう。

  • RuntimeShader を使用する場合は、1 回だけ作成する必要があります。コンパイルとキャッシュのみが行われますが、作成するたびにソース文字列が解析されます。

  • また、エフェクトがフレームごとにアニメーション化される場合は、graphicsLayer でラップすることで、アニメーション化される要素のみが再記録され、周囲の UI は再描画されません。

  • [一番下に移動] ボタンは、スクロールすると常に表示されます。

  • この例では、2 つの RuntimeShader を使用してその効果を示しています。ボタンが表示されるたびに、両方のシェーダーがソース文字列から再作成されます。

    val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply {
        setFloatUniform("size", size.width, size.height)
        setFloatUniform("zoom", zoom)
    // …
    val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content")
    // …
    val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply {
  • これら 2 つのシェーダーを解析してエフェクトをビルドするには、ボタンが表示されるたびにメインスレッドで数百マイクロ秒かかります。

  • 代わりに、プロセス全体でシェーダーを 1 回作成するか、シェーダーを記憶して、そのユニフォームを更新します。

    val lensShader = MagnifierShaders.lens.apply {
        setFloatUniform("size", size.width, size.height)
        setFloatUniform("zoom", zoom)
    // …
    private object MagnifierShaders {
        val lens: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(MAGNIFIER_LENS_SHADER) }
        val glassFinish: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(GLASS_FINISH_SHADER) }
  • この変更により、そのエフェクトの構築が 40 倍近く高速になり、ボタンが表示されたときのコストが 20 分の 1 に削減されます。

  • 先ほど見たメッシュ グラデーションのグローでは、drawBehind 内でフレームごとにグローがアニメーション化されます。

    Box(
        modifier = Modifier
            .matchParentSize()
            .drawBehind {
                // Behind the card, scaled past its bounds.
                translate(top = -58.dp.toPx()) {
                    scale(scaleX = 1.38f, scaleY = 2.85f) {
                        with(glowMeshPainter) {
    draw(size, alpha = glowAlpha)
    }
  • そのため、描画フェーズで状態を読み取ると、コンポジションとレイアウトは維持されますが、ディスプレイ リストは単一の描画コマンドをその場で更新できません。

  • そのため、Compose はツリーを上にたどって、グラフィック レイヤを持つ最も近いノードを見つけます。この場合は、画面全体である赤い枠線です。そして、フレームごとにそのレイヤ全体を再記録します。

  • graphicsLayer をボックスに直接配置することで、緑色の枠線で移動が停止するため、グローボックスのみが再記録され、メインスレッドの描画スライスが 0.2 ミリ秒短縮されます。

    Box(
        modifier = Modifier
            .matchParentSize()
                      .graphicsLayer()
            .drawBehind {
                // Behind the card, scaled past its bounds.
                translate(top = -58.dp.toPx()) {
                    scale(scaleX = 1.38f, scaleY = 2.85f) {
                        with(glowMeshPainter) {
    draw(size, alpha = glowAlpha)
    }
  • Perfetto では、トレースで何が問題になっているかを分析するのが難しい場合があります。そのため、トレース分析スキルを使用して分析情報を収集できます。

  • パフォーマンスの高い Compose コードを記述するためのアシスタントが必要な場合は、Android CLI をインストールし、Compose のパフォーマンス スキルを使用して開始してください。

    android skills add perfetto-trace-analysis
    android skills add jetpack-compose-performance

まとめとリソース

  • 本日ご紹介した例を確認するには、こちらのリンクから JetChat アプリをご覧ください。

コード

コード スニペット

「メッシュ グラデーションとカスタム メッシュ レンダリング」の 2 分 47 秒の Kotlin スニペット
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 2 分 55 秒の Kotlin スニペット
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
   setVertex(0, 0, Offset(0.0f, 0.0f), indigo)

}
}
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 3 分 5 秒の Kotlin スニペット
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
   setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
     // ....
   setVertex(1, 1, Offset(0.2f, 0.4f) + Offset(animatedOffset, animatedOffset), coral)
   // ....
   setVertex(3, 0, Offset(0.0f, 1.0f), sunshine)
   // ....
}
}

Box(
   modifier = modifier.padding(32.dp)
       .safeContentPadding()
       .aspectRatio(16 / 9f)
       .fillMaxWidth()
       .paint(gradientPainter)
)
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 3 分 24 秒の Kotlin スニペット
MeshGradientPainter(
   rows = 2,
   columns = 3,
   hasBicubicColor = true,
) {
   // Row 0 (top edge, y = 0.0f)
   setVertex(0, 0, Offset(0.0000f, 0.0000f), Color(0xFFFF6B6B))
   setVertex(0, 1, Offset(0.3300f, 0.0000f), Color(0xFFFFD6D6))
   setVertex(0, 2, Offset(0.6700f, 0.0000f), Color(0xFFE8C3FF))
   setVertex(0, 3, Offset(1.0000f, 0.0000f), Color(0xFFE396FF))

   // ... remaining vertices for Rows 0, 1, and 2
}
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 04:09 の Kotlin スニペット
val transition = rememberInfiniteTransition(label = "userInputGlowMesh")

val phase1 by transition.animateFloat(
   initialValue = 0f,
   targetValue = (2 * PI).toFloat(),
   animationSpec = infiniteRepeatable(
       animation = tween(durationMillis = 2800, easing = LinearEasing),
       repeatMode = RepeatMode.Restart,
   ),
   label = "glowPhase1",
)

val phase2 by transition.animateFloat(
   initialValue = 0f,
   targetValue = (2 * PI).toFloat(),
   animationSpec = infiniteRepeatable(
       animation = tween(durationMillis = 4200, easing = LinearEasing),
       repeatMode = RepeatMode.Restart,
   ),
   label = "glowPhase2",
)
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 4 分 33 秒の Kotlin スニペット
MeshGradientPainter(
   rows = 6,
   columns = 5,
   hasBicubicColor = true,
) {
   for (r in 0..6) {
       for (c in 0..5) {
               val offset = if (r in 1..5 && c in 1..4) {
               val ampX = when (r) {
                   1, 2 -> 0.048f
                   3 -> 0.042f
                   4 -> 0.028f
                   else -> 0.036f
               }
               val ampY = when (r) {
                   1, 2 -> 0.028f
                   3 -> 0.020f
                   4 -> 0.022f
                   else -> 0.016f
               }
               val d = orbit(c, r, ampX, ampY)
               Offset(
                   x = (baseU + d.x).coerceIn(0.04f, 0.96f),
                   y = (baseV + d.y).coerceIn(0.05f, 0.95f),
               )
           } else {
               Offset(baseU, baseV)
           }
           setVertex(r, c, offset, gridColors[r][c])
       }
   }
}
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 5 分 40 秒の Kotlin スニペット
val lines = remember {
        path.asAndroidPath().flatten(0.5f)
    }
//... inside draw scope
lines.forEach { line ->
//...
    drawLine(
        brush = Brush.linearGradient(...),
        start = Offset(line.start.x,
line.start.y),
        end = Offset(line.end.x, line.end.y),
        strokeWidth = 30f,
    )
//...
}
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 07:03 のテキスト スニペット
LineShaders.VERTEX

uniform float2 uStart;
uniform float2 uEnd;
uniform float uHalfWidth;
uniform float uTime;

Varyings main(const Attributes a) {
    Varyings v;
    float2 dir = uEnd - uStart;
    float2 normal = normalize(float2(-dir.y, dir.x));
    float along = a.corner.x * length(dir);
    float pulse = (a.corner.x - uTime) / 0.18;
    float radius = uHalfWidth * (1.0 + 0.6 * exp(-pulse * pulse));
    float across = a.corner.y * (radius + 1.0);
    v.position = uStart + dir * a.corner.x + normal * across;
    v.stroke = float3(along, across, radius);
    return v;
}
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 08:01 のテキスト スニペット
LineShaders.FRAGMENT

uniform float uLength;
layout(color) uniform float4 uStartColor;
layout(color) uniform float4 uEndColor;

float2 main(const Varyings v, out float4 color) {
    float distance = abs(v.stroke.y);
    float coverage = 1.0 - smoothstep(
    v.stroke.z - 0.5, v.stroke.z + 0.5, distance);
    float side = distance / v.stroke.z;
    float4 ink = mix(uStartColor, uEndColor,
        clamp(v.stroke.x / uLength + 0.4 * side * side, 0.0, 1.0));
    color = ink * coverage;
    return v.position;
}
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 8 分 37 秒の Kotlin スニペット
val spec = remember {
    MeshSpecification.make(
        arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
        vertexStride = 8,
        arrayOf(Varying(TYPE_FLOAT3, "stroke")),
        LineShaders.VERTEX, LineShaders.FRAGMENT)
}
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 9:06 の Kotlin スニペット
fun lineCorners(segments: Int): ByteBuffer {
    val buffer = ByteBuffer.allocateDirect((segments + 1) * 2 * 8)
        .order(ByteOrder.nativeOrder())
    for (i in 0..segments) {
        val t = i / segments.toFloat()
        buffer.putFloat(t).putFloat(-1f) // one side
        buffer.putFloat(t).putFloat(1f)  // the other side
    }
    return buffer.rewind() as ByteBuffer
}
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 9:18 の Kotlin スニペット
val mesh = Mesh(
    spec,
    mode = Mesh.TRIANGLE_STRIP,
    vertexBuffer = corners,
    vertexCount = (segments + 1) * 2,
    bounds = RectF(0f, 0f, size.width, size.height)
)
「メッシュ グラデーションとカスタム メッシュ レンダリング」の 9:24 の Kotlin スニペット
Box(Modifier.fillMaxSize().drawWithCache {
   val mesh = Mesh(...)
   onDrawBehind {
       mesh.setFloatUniform("uTime", time.value)
       drawIntoCanvas {
          it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
       }
   }
})
「広色域のサポート」の 11:22 の XML スニペット
<!-- AndroidManifest.xml -->
<activity
android:name=".NavActivity"
android:colorMode="wideColorGamut"
android:exported="true">
</activity>
「広色域のサポート」の 11:24 の Kotlin スニペット
// Dynamic toggle in code
DisposableEffect(window) {
   window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
   onDispose {
       window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
   }
}
「広色域のサポート」の 11:49 の Kotlin スニペット
// 1. Display P3 Color (Wider gamut: greens & reds are more saturated) colorMode=wideColorGamut
val VividP3Red = Color(
   red = 1.0f,
   green = 0.05f,
   blue = 0.05f,
   alpha = 1.0f,
   colorSpace = ColorSpaces.DisplayP3
)
// 2. Extended sRGB (Enables HDR values > 1.0f) use colorMode=hdr
val HdrNeonCyan = Color(
   red = 0.0f,
   green = 1.8f,  // Peak brightness > 1.0 in extended gamut
   blue = 2.0f,
   alpha = 1.0f,
   colorSpace = ColorSpaces.ExtendedSrgb
)
// 3. BT.2020 HLG (Broadcast HDR standard, supported API 34+) - use colorMode=hdr
val HdrSunsetOrange = Color(
   red = 1.0f,
   green = 0.45f,
   blue = 0.1f,
   alpha = 1.0f,
   colorSpace = ColorSpaces.Bt2020Hlg
)
「多角形とモーフィング」の 12:45 の Kotlin スニペット
fun cookiePolygon(): RoundedPolygon {
    val vertices = FloatArray(32)
    for (i in 0 until 16) {
        val radius = if (i % 2 == 0) 1f else 0.84f
// …
    }
    return RoundedPolygon(
        vertices,
        rounding = CornerRounding(0.12f)
    )
}
「多角形とモーフィング」の 13:15 の Kotlin スニペット
class RoundedPolygonShape(
 polygon: RoundedPolygonShape
) : Shape {
    // …
    override fun createOutline(size: Size, layoutDirection: LayoutDirection,
    density: Density,
    ): Outline {
        path = polygon.toPath().asComposePath()
        // …
        matrix.scale(size.width / maxDimension, size.height / maxDimension)
        matrix.translate(-bounds.left, -bounds.top)
        // …
        return Outline.Generic(path)
    }
}
「多角形とモーフィング」の 13:30 の Kotlin スニペット
private val AvatarShape = PolygonShape {
    val vertices = List(16) { i ->
        val radius = size.minDimension / 2f * if (i % 2 == 0) 1f else 0.86f
        val angle = i * PI.toFloat() / 8f
        size.center + Offset(cos(angle), sin(angle)) * radius
    }
    polygon(vertices, rounding = CornerRounding(radius = 8.dp))
}

    // …
Modifier.clip(AvatarShape)
「多角形とモーフィング」の 13:53 の Kotlin スニペット
val recordShape = remember {
    MorphPolygonShape(
        start = PolygonShape.circle(),
        end = PolygonShape.star(
            numPoints = 9,
            innerRadiusRatio = 0.8f,
            outerRounding = CornerRounding(percent = 50),
        ),
    ) { shapeProgress.value }
}
「ぼかしと背景のレンダリング効果」の 14:37 の Kotlin スニペット
Modifier
   .blur(
       radiusX = 10.dp,
       radiusY = 10.dp,
       edgeTreatment = BlurredEdgeTreatment(
           shape = RoundedCornerShape(8.dp),
       ),
   )
「ぼかしと背景のレンダリング効果」の 15:15 にある Kotlin スニペット
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}
「ぼかしと背景のレンダリング効果」の 15:42 の Kotlin スニペット
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)
「ぼかしと背景のレンダリング効果」の 15:53 の Kotlin スニペット
BlurRadiusSpec.radialGradient(
center = center,
fallOffRadius = 300.dp,
startRadius = 0.dp,
endRadius = 24.dp,
)
「ぼかしと背景のレンダリング効果」の 16:07 の Kotlin スニペット
val customShader = remember {
   RuntimeShader(
       """
       uniform float2 size;
       uniform float time;

       half4 main(float2 coords) {
          // Diagonal wave mask
          float normalizedX = coords.x / size.x;
          float normalizedY = coords.y / size.y;
          float wave = sin((normalizedX + normalizedY) * 6.0 + time) * 0.5 + 0.5;
          return half4(0.0, 0.0, 0.0, wave);
       }
       """.trimIndent()
   )
}

val customBlurRadius = remember(customShader) {
   BlurRadiusSpec.shader(maxRadius = 24.dp) { customShader }
}

Modifier.blur {
       val w = size.width.toPx()
       val h = size.height.toPx()
       customShader.setFloatUniform("size", w, h)
       customShader.setFloatUniform("time", animatedTime)
       blurRadiusSpec = customBlurRadius
   },
「ぼかしと背景のレンダリング効果」の 17:40 の Kotlin スニペット
fun Modifier.backdropBlur(spec: BlurRadiusSpec, shape: Shape, tint: Color): Modifier =
   this then BackdropBlurElement(spec, shape, tint)

private class BackdropBlurNode(...) : Modifier.Node(), DrawModifierNode {
   private var renderNode: RenderNode? = null

   override fun ContentDrawScope.draw() {
       val node = getOrCreateNode()
       if (isDirty || sizeChanged) {
           node.setPosition(0, 0, widthPx, heightPx)
           node.setBackdropRenderEffect(spec.createRenderEffect(this))
       }
       drawIntoCanvas { it.nativeCanvas.drawRenderNode(node) }
       drawContent()
   }
}
「ぼかしと背景のレンダリング効果」の 18:09 の Kotlin スニペット
JetchatAppBar(
    modifier = modifier
        .backdropBlur(
            tint = MaterialTheme.colorScheme
                .surface.copy(alpha = 0.5f),
            shape = RectangleShape,
            spec = BlurRadiusSpec.uniform(5.dp)
        )
)
「ぼかしと背景のレンダリング効果」の 18:30 の Kotlin スニペット
JetchatAppBar(
    modifier = modifier
        .backdropBlur(
            tint = MaterialTheme.colorScheme
                .surface.copy(alpha = 0.5f),
            shape = RectangleShape,
            spec = BlurRadiusSpec.verticalGradient(
                listOf(
                    BlurStop(0.5f, 32.dp),
                    BlurStop(1f, 0.dp),
                ),
            )
        )
)
「ぼかしと背景のレンダリング効果」の 19:10 の Kotlin スニペット
val blur = RenderEffect.createBlurEffect(24f, 24f, Shader.TileMode.CLAMP)

// Boost saturation by 30% and apply blur together
val matrix = ColorMatrix().apply { setSaturation(1.3f) }
val vibrancy = RenderEffect.createColorFilterEffect(ColorMatrixColorFilter(matrix))
val vibrantBlur = RenderEffect.createChainEffect(vibrancy, blur)
「ぼかしと背景のレンダリング効果」の 19:23 の Kotlin スニペット
val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")

val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)
「ぼかしと背景のレンダリング効果」の 20:53 の Kotlin スニペット
val region = RoundedRectBlurRegion().apply {
   bounds = RectF(left, top, right, bottom)
   setCornerRadii(spec.cornerRadiusPx.coerceAtLeast(0f))
   alpha = spec.alpha.coerceIn(0f, 1f)
   blurRadius = spec.blurRadiusPx.coerceAtLeast(0f)
}

surfaceView.setBlurRegions(listOf(region))

surfaceView.setBlurRegions(emptyList()) // Clear
「RuntimeColorFilter」の 21:33 の Kotlin スニペット
@Language("AGSL")
private const val CYBERPUNK_SHADER = """
    vec4 main(half4 in_color) {
        float lum = dot(in_color.rgb, vec3(0.299, 0.587, 0.114));
        vec3 shadow = vec3(0.06, 0.04, 0.25);
        vec3 mid = vec3(0.92, 0.12, 0.58);
        vec3 high = vec3(0.18, 0.94, 0.98);
        vec3 col = lum < 0.5 ? mix(shadow, mid, lum * 2.0) : mix(mid, high, (lum - 0.5) * 2.0);
        return vec4(mix(in_color.rgb, col, 0.7), in_color.a);
    }
"""
「RuntimeColorFilter」の 21:48 の Kotlin スニペット
AsyncImage(
    model = imageUri,
    contentDescription = filter.displayName,
    colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
                   .asComposeColorFilter(),
    contentScale = ContentScale.Crop,
    modifier = Modifier.fillMaxSize(),
)
「グラフィック パフォーマンスの最適化」の 22:21 の Kotlin スニペット
@Test
fun scrollFling() = measureScroll(swipeSteps = 5)

private fun measureScroll(swipeSteps: Int) = rule.measureRepeated(
    packageName = TARGET_PACKAGE,
    metrics = defaultComposeScrollingMetrics(),
    compilationMode = CompilationMode.Full(),
    iterations = 5,
    setupBlock = {
        killProcess()
    }
//...
「グラフィック パフォーマンスの最適化」の 22:32 の SQL スニペット
select count(*) as late_frames
from actual_frame_timeline_slice
where upid = (select upid from process where name = 'com.example.compose.jetchat.perf')
  and jank_type like '%App Deadline Missed%'
「グラフィック パフォーマンスの最適化」の 23:54 の Kotlin スニペット
Image(
    painter = painterResource(it),
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
「グラフィック パフォーマンスの最適化」の 24:02 の Kotlin スニペット
AsyncImage(
    model = it,
    contentScale = ContentScale.Fit,
    modifier = Modifier.size(160.dp),
    contentDescription = stringResource(id = R.string.attached_image),
)
「グラフィック パフォーマンスの最適化」の 25:14 の Kotlin スニペット
val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply {
    setFloatUniform("size", size.width, size.height)
    setFloatUniform("zoom", zoom)
// …
val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content")
// …
val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply {
「グラフィック パフォーマンスの最適化」の 25:32 の Kotlin スニペット
val lensShader = MagnifierShaders.lens.apply {
    setFloatUniform("size", size.width, size.height)
    setFloatUniform("zoom", zoom)
// …
private object MagnifierShaders {
    val lens: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(MAGNIFIER_LENS_SHADER) }
    val glassFinish: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(GLASS_FINISH_SHADER) }
「グラフィック パフォーマンスの最適化」の 25:49 の Kotlin スニペット
Box(
    modifier = Modifier
        .matchParentSize()
        .drawBehind {
            // Behind the card, scaled past its bounds.
            translate(top = -58.dp.toPx()) {
                scale(scaleX = 1.38f, scaleY = 2.85f) {
                    with(glowMeshPainter) {
draw(size, alpha = glowAlpha)
}
「グラフィック パフォーマンスの最適化」の 26:15 の Kotlin スニペット
Box(
    modifier = Modifier
        .matchParentSize()
                  .graphicsLayer()
        .drawBehind {
            // Behind the card, scaled past its bounds.
            translate(top = -58.dp.toPx()) {
                scale(scaleX = 1.38f, scaleY = 2.85f) {
                    with(glowMeshPainter) {
draw(size, alpha = glowAlpha)
}
「グラフィック パフォーマンスの最適化」の 26:37 のシェル スニペット
android skills add perfetto-trace-analysis
android skills add jetpack-compose-performance