PHASE 3 · ANDROID 实战

3.4AGSL:Compose 时代的 Shader

RuntimeShader、RenderEffect 与 Modifier

写完 3.2/3.3 的几百行样板,来看现代路线:同样的效果,AGSL 只要几十行,而且能直接作用在你已有的 Compose UI 上——这是 GLSurfaceView 给不了的能力。因为管线概念你已经懂了,这一章会快得像开挂。

1AGSL 从哪来,和 GLSL 差在哪

Android 的整个 UI(View 和 Compose)由 Skia 图形引擎渲染,Skia 内部的着色语言叫 SkSL。Android 13 把它开放给开发者,起名 AGSL(Android Graphics Shading Language)。所以 AGSL Shader 不是「另开一块画布」,而是作为一个环节插进系统本来就在跑的渲染流程——这就是它能拿 UI 内容当输入的原因。

语言差异一张表(完整心智模型不变,只是改口音):

GLSL 300 es(本书 Demo)AGSL
文件头#version 300 es + precision不需要,直接写
入口void main() + out vec4half4 main(float2 fragCoord)
坐标来源gl_FragCoord(y 向上!)参数 fragCoord(y 向下,同 Canvas)
类型名vec2 / mat3float2 / float3x3(vec2 也兼容)
颜色精度mediump/highp 声明half(颜色)/ float(坐标)类型自带
采样输入图texture(uTex, uv) — uv 是 0~1content.eval(coord) — coord 是像素
装载方式glCreateShader / 编译 / 链接…RuntimeShader("源码字符串")
最容易翻车的两处

y 轴终于顺了:AGSL 的 fragCoord 原点在左上、y 向下,和 Android 坐标一致——从本书 Demo 移植时,所有依赖「y 向上」的效果要翻一次 uv.y = 1.0 - uv.y(或干脆接受镜像)。② eval 用像素坐标:GLSL 的 texture() 吃 0~1 的 UV,AGSL 的 eval() 吃真实像素——归一化和反归一化要自己换算。

2用法一:ShaderBrush 画东西

把 Shader 当画笔,适合动态背景、进度装饰、艺术卡片。以 1.4 章的 IQ 调色板为例,完整移植:

Kotlin · 流光背景卡片(API 33+)
private val FLOW_SHADER = """
    uniform float2 uResolution;
    uniform float uTime;

    half3 palette(float t) {
        return half3(0.5) + half3(0.5) * cos(6.28318 * (t + half3(0.0, 0.33, 0.67)));
    }

    half4 main(float2 fragCoord) {
        float2 uv = fragCoord / uResolution;          // 归一化,自己来
        float t = uv.x * 0.6 + uv.y * 0.2 + uTime * 0.08;
        half3 col = palette(t);
        // 一点渐晕(y 向下,但渐晕对称,无需翻转)
        col *= half(smoothstep(1.1, 0.35, length(uv - 0.5)));
        return half4(col, 1.0);
    }
"""

@Composable
fun FlowCard(modifier: Modifier = Modifier) {
    val shader = remember { RuntimeShader(FLOW_SHADER) }
    val time by rememberInfiniteTime()   // 见下

    Box(
        modifier
            .clip(RoundedCornerShape(24.dp))
            .drawWithCache {
                shader.setFloatUniform("uResolution", size.width, size.height)
                shader.setFloatUniform("uTime", time)
                val brush = ShaderBrush(shader)
                onDrawBehind { drawRect(brush) }
            }
    ) { /* 卡片内容照常叠在上面 */ }
}

/** 每帧递增的时间源:Compose 版的 uTime */
@Composable
fun rememberInfiniteTime(): State = produceState(0f) {
    val start = withFrameNanos { it }
    while (true) {
        withFrameNanos { now -> value = (now - start) / 1e9f }
    }
}

对比 3.2 章:没有 Surface、没有 Renderer、没有线程军规——Compose 的绘制系统全包了。setFloatUniform 随便在哪个线程调都安全,重组时自动重绘。

3用法二:RenderEffect 处理 UI

王牌功能:声明一个 uniform shader 类型的输入,系统会把这个 Modifier 所修饰的整块 UI 的渲染结果作为纹理喂进来——2.1 章「三把刀」的输入图,现在是你的真实界面。以 2.2 章马赛克为例:

Kotlin · 给任意 Composable 打马赛克
private val PIXELATE = """
    uniform shader content;        // ← 被修饰的 UI,系统自动喂
    uniform float uCell;           // 格子大小(像素)

    half4 main(float2 fragCoord) {
        // 2.2 章的量子化,直接用像素坐标做,连归一化都省了
        float2 cellCoord = floor(fragCoord / uCell) * uCell + uCell * 0.5;
        return content.eval(cellCoord);
    }
"""

fun Modifier.pixelate(cellPx: Float): Modifier = this.graphicsLayer {
    val shader = RuntimeShader(PIXELATE)
    shader.setFloatUniform("uCell", cellPx)
    renderEffect = RenderEffect
        .createRuntimeShaderEffect(shader, "content")   // "content" 对应声明名
        .asComposeRenderEffect()
}

// 用法:任何东西都能打码
Image(painter, null, Modifier.pixelate(24f))
Column(Modifier.pixelate(cellSize)) { /* 整块 UI 一起马赛克 */ }

cellPx 接上 animateFloatAsState,就是「内容逐渐清晰」的解锁动画;换成 2.3 章毛玻璃的随机偏移,就是会员内容的磨砂遮罩;换成 2.4 章 glitch,就是错误页面的故障艺术。Phase 2 的每个效果在这里都有一个 UI 岗位。

Android 视角 View 体系也能用

不用 Compose 的项目同样吃得到:view.setRenderEffect(RenderEffect.createRuntimeShaderEffect(...))(API 33+)。另外系统自带的 RenderEffect.createBlurEffect() 从 API 31 就有——只要模糊不要花活时,优先用它,系统实现有降采样优化。

4GLSL → AGSL 移植清单

把本书 Demo(或 Shadertoy 作品)搬进 AGSL 时,顺着这张清单机械操作即可:

  1. 删掉 #version 300 esprecision 行;
  2. out vec4 fragColor; void main()half4 main(float2 fragCoord),所有 fragColor = … 改为 return …;
  3. gl_FragCoord.xyfragCoord;如效果不对称,补 uv.y = 1.0 - uv.y;
  4. texture(uTex, uv)uTex.eval(uv * uTexSize)(记得传一张图的尺寸,或用 uniform shader + 像素坐标);
  5. 颜色相关变量类型尽量改成 half/half3/half4(性能),坐标保持 float;
  6. Kotlin 侧:RuntimeShader(src) + setFloatUniform,uTime 用 withFrameNanos 驱动。

5边界与性能

  • API 33+。低版本降级方案:效果性内容直接不加(优雅降级),或关键场景走 GLSurfaceView。
  • 单 pass:一个 RuntimeShader 就是一趟 Fragment Shader,没有多 pass、没有自定义顶点、不能反馈循环(前一帧的输出当下一帧输入要绕道 Bitmap)。复杂管线仍是路线 A 的地盘。
  • 编译时机:RuntimeShader 构造时同步编译,源码有错直接抛异常——所以别在滚动回调里 new,老老实实 remember
  • 性能直觉照旧:AGSL 跑的还是那块 GPU,1.5 章「octave 别堆太多」、2.1 章「大模糊要降采样」的告诫原样适用。graphicsLayer 的 renderEffect 会强制离屏合成一层,别给列表里每个 item 都挂一个重效果。

本章小结

  • AGSL = 开放给开发者的 Skia 着色语言(API 33+),插在系统渲染流程里,所以能吃 UI 当纹理。
  • 语法差异口诀:头没了、main 收坐标返颜色、y 向下、eval 吃像素、颜色用 half。
  • 两种用法:ShaderBrush 当画笔画新内容;RenderEffect + uniform shader 加工已有 UI。
  • Phase 2 的所有效果按第 4 节清单机械移植即可;uTime 用 withFrameNanos 驱动。
  • 边界:单 pass、33+、构造即编译;多 pass 和相机流回路线 A。

动手练习

  1. 按移植清单把 2.4 章 CRT 效果搬进 AGSL,套在一张 Image 上,做一个「老电视相框」组件。
  2. 做「磨砂付费墙」:RenderEffect 版毛玻璃(2.3)盖住文章内容,强度接 animateFloatAsState,点按钮后 600ms 内变清晰。
  3. 压测一次:把你的效果挂到 LazyColumn 的每个 item 上滚动,打开 GPU 渲染分析条,亲眼看看离屏合成的代价,然后改成「只挂在可见的头图上」。