你的 Shader 内功已经练成,现在要把它装进 App。很多教程在这里只教一条路(而且往往是老路),导致你分不清「必须的」和「历史包袱」。这一章先把 Android 上运行 Shader 的两条路线看清楚,后面四章分头深入——磨刀不误砍柴工,这十分钟的全景值回票价。
1两条路线,一张地图
在 Android 上让一段 Fragment Shader 跑起来,今天有两条正规路线:
- 路线 A(经典):OpenGL ES + GLSurfaceView。你直接操作 GPU API:自己编译 Shader、自己管顶点、自己开渲染循环。语言就是本书用的 GLSL 300 es,Demo 代码原封不动可用。功能无上限(3D、多 pass、相机流),代价是样板代码多、与 UI 系统隔离(它是一块独立的 Surface)。
- 路线 B(现代):AGSL + RuntimeShader。Android 13(API 33)引入。你写一段 AGSL(和 GLSL 语法九成相同的方言),包成
RuntimeShader,像普通Shader/RenderEffect一样交给系统渲染管线(Skia)。它能直接作用于你现有的 View 和 Compose 内容——把整个 UI 当作输入纹理,这是路线 A 做不到的。代价是 API 门槛(13+)和一些功能边界。
两条路线最终都跑在同一块 GPU、同一种渲染管线上(0.1 章)。Skia(路线 B 的引擎)底层也是 GPU API。所以你在 Phase 0~2 学的一切——坐标、SDF、噪声、三把刀——两条路线通吃,差别只在「装配方式」。
2路线 A:OpenGL ES 与 GLSurfaceView
路线 A 的核心角色只有三个,先认脸,3.2/3.3 章逐个上手:
GLSurfaceView
专门用于 GL 渲染的 View。它替你管好两件麻烦事:EGL 环境(GPU 上下文与窗口的婚介所)和独立的渲染线程。
Renderer 接口
你实现三个回调:onSurfaceCreated(初始化)、onSurfaceChanged(尺寸变化)、onDrawFrame(每帧绘制)。
GLES30 类
OpenGL ES 3.0 的静态方法集,Kotlin 里所有 gl* 调用的入口,一比一对应 C API。
class ShaderSurfaceView(context: Context) : GLSurfaceView(context) {
init {
setEGLContextClientVersion(3) // 要求 OpenGL ES 3.0
setRenderer(ShaderRenderer())
renderMode = RENDERMODE_CONTINUOUSLY // 每帧重绘(动画用)
// 静态效果改用 RENDERMODE_WHEN_DIRTY + requestRender(),省电
}
}
class ShaderRenderer : GLSurfaceView.Renderer {
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
// 一次性:编译链接 Shader、准备顶点(3.2 章)
}
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
GLES30.glViewport(0, 0, width, height) // 告诉 GL 画布多大
}
override fun onDrawFrame(gl: GL10?) {
// 每帧:清屏 → 传 uniform → 画全屏两个三角形(3.2/3.3 章)
}
}
Renderer 的三个回调运行在 GLSurfaceView 私有的 GL 线程,不是主线程。所有 GLES30.* 调用只能在这个线程发生;主线程想传数据,要么用 @Volatile 字段让 GL 线程下一帧读取,要么 queueEvent { } 把代码块投递过去。在主线程调 GL 函数不会崩,只会静默无效——这是路线 A 最阴的坑。
3路线 B:AGSL 与 RuntimeShader
路线 B 没有 Surface、没有渲染线程、没有 gl* 调用。你写一段 AGSL 字符串,三行 Kotlin 就能把它糊到任何 Compose 元素上:
val SHADER = """
uniform float2 uResolution;
uniform float uTime;
half4 main(float2 fragCoord) { // AGSL:main 直接收坐标、返回颜色
float2 uv = fragCoord / uResolution;
half3 col = half3(uv.x, uv.y, abs(sin(uTime)));
return half4(col, 1.0);
}
"""
@Composable
fun ShaderBox() {
val shader = remember { RuntimeShader(SHADER) }
val time by produceState(0f) { // 简易时间源
while (true) { withInfiniteAnimationFrameMillis { value = it / 1000f } }
}
Box(Modifier.fillMaxSize().drawWithCache {
shader.setFloatUniform("uResolution", size.width, size.height)
shader.setFloatUniform("uTime", time)
val brush = ShaderBrush(shader)
onDrawBehind { drawRect(brush) } // Shader 当画笔,想画哪画哪
})
}
注意 AGSL 和 GLSL 的几处表面差异(3.4 章有完整对照表):没有 #version 和 precision 声明、vec2 写作 float2、main 的签名是「收坐标返颜色」。肌肉记忆三分钟迁移,思维模型零迁移。
4怎么选:决策表
| 你的需求 | 选择 | 为什么 |
|---|---|---|
| 给 Compose UI / 图片加特效滤镜 | 路线 B | RenderEffect 直接吃 UI 内容当纹理,十行代码的事 |
| 动态壁纸、全屏艺术背景 | 路线 B(13+) | ShaderBrush 铺满即可;要兼容老设备则 A |
| minSdk < 33 且必须有 Shader 特效 | 路线 A | GLSurfaceView 上古就有;或接受低版本降级无特效 |
| 相机 / 视频帧实时处理 | 路线 A | SurfaceTexture 外部纹理是 GL 的地盘 |
| 3D、多 pass 渲染、粒子系统 | 路线 A(或游戏引擎) | AGSL 单 pass 2D 为主,复杂管线要完整 GL |
| 学习、原型、给设计师演示 | 浏览器 | 本书 Demo / Shadertoy 迭代最快,调完再移植 |
路线 A 的样板代码(编译、链接、顶点、uniform)恰好就是渲染管线概念的一比一落地——写一遍,0.1 章的每个名词都长出了实体。路线 B 帮你把这些全藏掉了,先学 B 你会「能用但不知其所以然」,又回到抄代码的老路。3.2/3.3 苦一点,3.4 你会觉得 AGSL 简单得像作弊。
本章小结
- 两条路线:A = OpenGL ES + GLSurfaceView(全功能、全版本、样板多);B = AGSL + RuntimeShader(API 33+,能吃 UI 当纹理,门槛低)。
- Shader 知识两条路线通吃,差别只在装配方式;AGSL 是 GLSL 的近亲方言。
- 路线 A 三角色:GLSurfaceView(管环境和线程)、Renderer(三回调)、GLES30(API 入口);GL 调用只能在 GL 线程。
- 选型速记:UI 特效选 B,相机/3D/老设备选 A,写效果本身永远先在浏览器调好。
动手练习
- 建一个空 Android 项目,把「路线 A 最小骨架」跑起来:在 onDrawFrame 里只写
glClearColor(1f, 0.4f, 0.2f, 1f)+glClear,看到一块橙色就算通关(Shader 下一章才上)。 - 如果你的测试机是 Android 13+:把「路线 B 最小示例」贴进 Compose 项目跑通,对照本书 0.2 章的 UV 渐变 Demo,确认画面一致。
- 盘点你手头的真实需求(或想做的效果),用决策表给它选路线,想清楚理由。