本文译自「GPU-Accelerated Effects: Glitch at Scale」,原文链接medium.com/@konstantin…,由Konstantin Zolotov发布于2025年11月9日。
几周前,我看到了Sina Samaki撰写的一篇关于使用Jetpack Compose制作故障效果的精彩文章。作为一个喜欢钻研底层技术的人,我看到了使用Android AGSL着色器重现这种效果并比较两种实现方式的绝佳机会。
在图形处理方面,选择合适的工具至关重要,因为很容易达到性能瓶颈,而扩展解决方案则变得困难。这里的情况是否如此呢?让我们一探究竟!
做好准备,我们将深入底层。
着色器(Shader)的本质
那么,着色器究竟是什么?
着色器是一种直接在 GPU 上执行的程序,并且可以并行执行。
着色器通常使用一种特殊的类 C 语言编写,在 Android Compose 中,这种语言是 AGSL——Android 图形着色语言。
我不会重复官方指南的内容,而是会简单介绍一下 GPU 以及一种新的着色器编程思维模型。
那么,它与 CPU 有什么区别呢? CPU 和 GPU 的主要区别基本上如下:
CPU:
- 更复杂
- 专为执行大量不同任务的大型程序而设计
- MIMD(多指令多数据流)
GPU:
- 简单得多(没有分支预测,缓存更小)
- 专为对各种数据执行完全相同操作的小型程序而设计
- 更多核心 = 更高的并行性
- SIMD(单指令多数据流)
当然,CPU 也有 SIMD 扩展,但规模远不及 GPU。
GPU 非常适合对数百万像素执行相同的操作
这里存在一个非常重要的思维模型转变:以前你可以在画布的任意位置绘制,而现在你面对的是一幅图像,你可以对图像的任何部分进行采样(读取),但输出结果始终是一个像素。每个目标像素都会执行相同的着色器。这类似于一个纯函数,不会产生任何副作用,因此像素仅取决于其坐标和提供的 uniform 变量。
让我们开始实现吧,但首先,分析原始合成版本中的关键点,并将这些想法转化为着色器思维模型。
关键的动画驱动因素是步长。动画器会在 500 毫秒的周期内,将浮点数值从 10 递减到 0。步长状态为整数,由于浮点数会被转换为整数,因此共有 11 个步长。
var step by remember { mutableStateOf(0) }
LaunchedEffect(key) {
Animatable(10f)
.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 500,
easing = LinearEasing,
)
) {
step = this.value.roundToInt()
}
}
此外,还有一个名为强度的参数,它基于步长计算:
val intensity = step / 10f
因此,强度是一个数值序列 [1.0, 0.9, …, 0.0]。
下一个关键点是切片:
for (i in 0 until slices) {
translate(
left = if (Random.nextInt(5) < step)
Random.nextInt(-20..20).toFloat() * intensity
else
0f,
) {
scale(
scaleY = 1f,
scaleX = if (Random.nextInt(10) < step)
1f + (1f * Random.nextFloat() * intensity)
else
1f,
) {
clipRect(
top = (i / slices.toFloat()) * size.height,
bottom = (((i + 1) / slices.toFloat()) * size.height) + 1f,
) {
layer {
drawLayer(graphicsLayer)
if (Random.nextInt(5, 30) < step) {
drawRect(
color = glitchColors.random(),
blendMode = BlendMode.SrcAtop,
)
}
}
}
}
}
}
对于每个切片,都会应用以下变换:
1. 平移
- 在步骤 10 到 5 期间,每个切片会随机移动 -20 到 20 范围内的像素。请注意,每一步操作都会使该范围缩小,因为它会乘以强度。
- 在步骤 4 到 0 中也会发生类似的情况,但并非每个切片都会移动,有些切片不会被移动。
2. 水平缩放 每个切片都会根据强度乘以 1.0 到 2.0 范围内的随机数进行缩放,每一步操作都会降低缩放的概率和大小。
3.彩色条纹
- 在步骤 10 到 5 中,在每个切片上绘制一条随机彩色条纹,初始概率为 0.2,到步骤 5 时降至 0。
- 步骤 5 之后不再绘制条纹。
因此,总体而言,动画在前几个步骤中最具表现力,并在后半部分逐渐趋于稳定。
对于着色器而言,强度应该足以驱动动画,而无需使用步骤。在 Kotlin 代码中,我仍然会使用步骤 + 强度,仅仅是为了尽可能地复现动画效果,并用于未来的性能测试。
思维模型转变: 着色器是逐像素执行的,但动画会将相同的变换应用于像素组(在本例中为切片)。为了在着色器中实现这一点,我们需要对整个切片进行完全相同的计算。还记得纯函数的相似性吗?它在这里非常有用,因为要得到相同的结果,我们只需要应用相同的参数!
具有相同变换的像素组就是一个切片:
uniform shader image;
uniform float2 imageSize; // Shader area size in pixels
uniform float intensity;
uniform int slices;// fragCoord — pixel coordinates
half4 main(float2 fragCoord) {
// Create horizontal slices
float sliceHeight = imageSize.y / float(slices); // Height of each slice in pixels
float sliceY = floor(fragCoord.y / sliceHeight) * sliceHeight; // Start coordinates for each slice
// ...
}
让我们一步一步来,从平移开始。
平移
步骤 10 到 5 等价于强度从 1.0 到 0.5,每次递减 0.1。因此,我们将以此为基础来移动切片:
// Simple random functions
float random(float seed) {
return fract(sin(seed) * 100000.0);
}
float random(float2 st) {
return fract(sin(dot(st.xy, float2(12.9898, 78.233))) * 43758.5453123);
}
// Determine how much this slice should be displaced
float displace(float sliceY, float intensity) {
float rnd = random(float2(sliceY, intensity));
float shouldDisplace;
if (intensity < 0.5 && intensity > rnd * 0.4) {
shouldDisplace = 0.0;
} else {
shouldDisplace = 1.0;
}
return (rnd - 0.5) * 40.0 * intensity * shouldDisplace
}
这里发生了什么?
首先,是随机性。由于着色器本身没有随机性,因此通常使用一些函数来模拟随机性。这两个函数都会返回一个介于 0(含)和 1(不含)之间的浮点值。在这种情况下,“随机”值对于每个切片-帧组合都是唯一的。对于给定帧,均匀强度对于所有调用(= 输出像素)都是相同的,同一组像素的切片坐标也相同。结合切片起始坐标,这个值对于每一帧的每个切片都是不同的。
接下来,如果强度超过 0.5,shouldDisplace 因子会立即设置为 1.0,这意味着切片需要进行位移。否则,intensity > rnd * 0.4 会导致执行位移的概率下降,类似于原始实现中的 Random.nextInt(5) < step。
最后一行只是简单的算术运算。这里我将 0 到 1 的伪随机值转换为 -20 到 20,然后像原始实现一样乘以强度,并在发生位移时应用该因子。
缩放
屏幕空间缩放本质上是指相对于枢轴点(本例中为水平中心)调整像素采样坐标。由于我们是从源图像读取数据,因此实际上是移动了采样视口。
float2 scale(float2 coord, float yMin, float yMax, float screenWidth, float intensity) {
float rnd = random(float2(yMin, intensity));
if (coord.y >= yMin && coord.y <= yMax && rnd < intensity) {
float centerX = screenWidth * 0.5;
float localX = coord.x - centerX;
float scaleFactor = 1f + (intensity * rnd);
localX /= scaleFactor;
float scaledX = localX + centerX;
return float2(scaledX, coord.y);
}
return coord;
}
附注:为了演示,我尽量简化了逻辑。生产环境中的着色器代码通常会使用更高级的技术来避免分支,因为像上面示例中那样的不平衡分支会迫使 GPU 串行执行两条路径,从而破坏并行性。要以优化的方式实现缩放函数并非易事,所以我采用了一种简单的方法。
再次强调,对于每一帧的每个切片,随机数都是唯一的,这意味着特定切片中的每个像素都会获得相同的值。因此,rnd < intensity 会降低概率,类似于 Random.nextInt(10) < step。
彩色条纹
最简单的部分。类似地,如果应用了色带,则创建相同的概率,然后选择 3 种颜色中的一种。可以使用非硬编码值,但这需要一些额外的工作,因此 Compose 版本在这方面更灵活。
float rnd = random(float2(intensity, sliceY));
if ((rnd * 2.5 + 0.5) < intensity) {
if (rnd > 0.67) {
return yellow;
} else if (rnd > 0.33) {
return red;
} else {
return cyan;
}
} else {
float2 scaled = scale(displaced, sliceY, sliceY + sliceHeight, imageSize.y, intensity);
return image.eval(scaled);
}
将所有内容组合在一起后,结果如下:
存在一个明显的问题:叠加层的颜色始终为青色。这是因为伪随机函数对相同的输入返回相同的值——这是设计上的确定性行为。解决方案:在 Kotlin 端生成真正的随机数,并将其作为 uniform 变量传递。
-float rnd = random(float2(intensity, sliceY));
+float rnd = random(float2(intensity * realRandom, sliceY));
if ((rnd * 2.5 + 0.5) < intensity) {
-if (rnd > 0.67) {
+if (realRandom > 0.67) {
return yellow;
-} else if (rnd > 0.33) {
+} else if (realRandom > 0.33) {
return red;
} else {
return cyan;
}
} else {
float2 scaled = scale(displaced, sliceY, sliceY + sliceHeight, imageSize.y, intensity);
return image.eval(scaled);
}
应用适当的随机性后,整个过程与原始行为非常接近:
完整代码发布于此处。
当然,在进行图形编程时,至少需要进行一些粗略的性能观察。为此,我将使用我的 Pixel 7,启用 HWUI 渲染图表,并稍作修改代码:使用 infiniteRepeatable 规范实现循环动画,并使用发布版本。Pixel 7 实际上非常适合这项任务,因为它并非高端设备,如果它在 Pixel 7 上有效,那么在性能更高的设备上也应该有效。
左侧为着色器,右侧为Compose
乍一看,这两个图表似乎很相似,但实际上存在一个问题:当前的实现方式隐式地限制了帧速率。动画会将浮点数值从 10 递减到 0,但状态更新时会使用四舍五入为整数的值。这意味着在 500 毫秒内只有 11 帧动画。这对于故障着色器来说非常方便,因为较低的帧速率也会增强故障效果。要消除这个限制,我们只需要将步长类型从整数 (Int) 更改为浮点数 (Float),并使用不进行四舍五入的可动画值即可。
-var step by remember { mutableStateOf(0) }
+var step by remember { mutableFloatStateOf(0f) }
LaunchedEffect(key) {
Animatable(10f)
.animateTo(
targetValue = 0f,
animationSpec = tween(
durationMillis = 500,
easing = LinearEasing,
)
) {
- step = this.value.roundToInt()
+ step = this.value
}
}
左侧为着色器,右侧为Compose,无帧数限制
移除帧数限制后,性能提升已经非常明显。我们来做个压力测试。如果将动画应用于整个列表会发生什么?或者切片数量增加会发生什么?让我们拭目以待!
左侧着色器,右侧Compose,无帧数限制,应用于整个列表
左侧着色器,右侧Compose,无帧数限制,应用于整个列表100 个切片
结论
Jetpack Compose非常适合用于复杂动画的原型设计,因为它可以使用熟悉的工具轻松实现。此外,值得一提的是,它适用于所有设备。
另一方面,着色器提供了性能更高、更稳定的渲染。在这种情况下,使用的切片数量无关紧要——无论是 20 个还是 500 个,计算量都没有区别,而纯 Compose 版本对此非常敏感,并且会随着切片数量的增加而线性增长。
此外,由于 AGSL 着色器只是在运行时临时编译的文本,因此理论上可以从后端更新这些动画。
但是,还有一个很大的问题:着色器从 Android 13 开始可用,因此根据 Android Studio 操作系统的发行情况,大约一半的设备将能够支持这种方法。当然,这种情况会随着时间的推移而改变,我希望我们能够利用着色器充分发挥图形编程的强大功能!
这是我的bento,如果你想联系我、聊天或讨论,欢迎来找我!
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!