Phase 3 的毕业项目:一个完整的 Shader 转场系统——两张画面,一个 0~1 的进度,像素级别的华丽切换。这也是开源世界研究最充分的 Shader 场景(GL Transitions 项目收录了上百种),学会框架后,那上百种全是你的素材库。
1转场的通用框架
一切 Shader 转场都是同一个函数:
uniform sampler2D uTexFrom; // 旧画面
uniform sampler2D uTexTo; // 新画面
uniform float uProgress; // 0.0 = 全旧,1.0 = 全新
// 每个转场唯一的差别:mask 怎么随 progress 和坐标变化
float mask = transitionMask(uv, uProgress);
fragColor = mix(texture(uTexFrom, uv), texture(uTexTo, uv), mask);
1.1 章的老朋友:造 mask,然后 mix。最笨的 mask 是 mask = uProgress(整屏淡入淡出);而所有炫酷转场,不过是让 mask 在空间上不同步——圆形揭示是「离中心近的先变」,百叶窗是「每条先后变」,故障转场是「随机块乱序变」。
2三个转场,一个滑块
Demo 里「旧画面」是照片,「新画面」用 1.4 章调色板现场生成(省一张纹理,还复习了知识)。拖滑块就是在手动播放转场:
注意②里的细节:擦除时旧画面还额外做了一个小位移(拖影)——被替换的一方也参与运动,转场立刻从「换图」升级成「有物理感的推挤」。这类小心思是 GL Transitions 里最值得偷师的部分。
3Kotlin 端:双纹理 Renderer
3.2/3.3 的积木直接拼:多一张纹理、多一个 uProgress,没有任何新 API:
class TransitionRenderer(private val context: Context) : GLSurfaceView.Renderer {
private var program = 0
private var texFrom = 0
private var texTo = 0
@Volatile var progress = 0f // 主线程写(动画),GL 线程读
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
program = ShaderUtil.createProgram(VERT_SRC, TRANSITION_FRAG)
texFrom = loadTexture(context, R.drawable.page_from) // 3.3 章的函数
texTo = loadTexture(context, R.drawable.page_to)
// …VAO/VBO 与 location 缓存,同 3.2/3.3
}
override fun onDrawFrame(gl: GL10?) {
GLES30.glClear(GLES30.GL_COLOR_BUFFER_BIT)
GLES30.glUseProgram(program)
// 双纹理绑定(3.3 章口诀:纹理插单元,sampler 记单元号)
GLES30.glActiveTexture(GLES30.GL_TEXTURE0)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texFrom)
GLES30.glUniform1i(uTexFromLoc, 0)
GLES30.glActiveTexture(GLES30.GL_TEXTURE1)
GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, texTo)
GLES30.glUniform1i(uTexToLoc, 1)
GLES30.glUniform1f(uProgressLoc, progress)
GLES30.glUniform2f(uResLoc, width.toFloat(), height.toFloat())
GLES30.glBindVertexArray(vao)
GLES30.glDrawArrays(GLES30.GL_TRIANGLE_STRIP, 0, 4)
}
}
实战中「两张画面」从哪来?静态素材用 drawable;真实页面转场则在切换前把两个页面分别 View.drawToBitmap()(或 Compose 的 rememberGraphicsLayer().toImageBitmap())截成 Bitmap 再上传——转场期间显示 GLSurfaceView,结束后无缝换回真实页面。
4Compose 驱动进度
1.4 章说过的分工在这里落地:缓动交给动画框架,像素交给 Shader:
@Composable
fun TransitionScreen() {
var showNext by remember { mutableStateOf(false) }
val progress by animateFloatAsState(
targetValue = if (showNext) 1f else 0f,
animationSpec = tween(700, easing = FastOutSlowInEasing), // 缓动在这
label = "transition"
)
var renderer by remember { mutableStateOf(null) }
// progress 变化 → 写进 renderer(@Volatile 字段,跨线程安全)
LaunchedEffect(progress) { renderer?.progress = progress }
Box(Modifier.fillMaxSize()) {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
GLSurfaceView(ctx).apply {
setEGLContextClientVersion(3)
val r = TransitionRenderer(ctx)
renderer = r
setRenderer(r)
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
}
}
)
Button(
onClick = { showNext = !showNext },
modifier = Modifier.align(Alignment.BottomCenter).padding(32.dp)
) { Text(if (showNext) "返回" else "切换") }
}
}
API 33+ 可以整个换成 AGSL:两张 Bitmap 各包成 BitmapShader,用 runtimeShader.setInputShader("from", …) / setInputShader("to", …) 喂进去,进度照样 setFloatUniform——连 GLSurfaceView 都省了,直接画在 Compose Canvas 上。框架不变,壳更薄。
5移植方法论:从 Shadertoy 到 App
毕业前把「抄效果」这件事升格成方法论。以后在 Shadertoy / GL Transitions 看到心动的效果:
- 读懂输入:它用了哪些 uniform?(iChannel0 = 你的纹理,iTime = uTime,iResolution = uResolution,iMouse = uMouse——Shadertoy 的命名对照就这四个);
- 浏览器里拆解:贴进本书任意 Demo 的编辑器,按 0.2 章调试三板斧逐段注释/输出中间值,搞清每段贡献了什么;
- 裁剪:删掉手机跑不动的部分(超多 octave、几百次循环采样),用 1.5/2.1 章的降本技巧替换;
- 换头:GLSL 300 es 直接进路线 A;按 3.4 章清单转 AGSL 进路线 B;
- 接线:效果参数 → uniform → Compose 状态/动画,收工。
从此「很多 GLSL 代码都是抄的,根本不知道为什么」这句话,对你失效了。
本章小结
- 转场 =
mix(from, to, mask(uv, progress));炫酷程度取决于 mask 在空间上怎么不同步。 - 被替换的一方也要参与运动(拖影、缩放),质感立升一级。
- Kotlin 端零新知识:双纹理绑定 + uProgress,全是 3.2/3.3 的积木。
- 分工铁律:动画框架管进度和缓动,Shader 管像素;@Volatile 或 setFloatUniform 交接。
- 移植五步法:读输入 → 浏览器拆解 → 裁剪 → 换头(GLSL/AGSL)→ 接线。
动手练习
- 在 Demo 里加第四种转场「百叶窗」:uv.x 切 12 条,每条按
fract内坐标先后翻转(提示:mask 用条内坐标与进度比较)。 - 给圆形揭示加「起点参数」:uniform vec2 uCenter,从点击位置扩散(结合 3.3 章触摸桥)——这就是 Material 的涟漪转场。
- 去 gl-transitions.com 挑一个你喜欢的转场,按第 5 节五步法完整移植到 Demo 编辑器里跑通。