稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

Compose GPU加速特效:带缩放的故障效果

本文译自「GPU-Accelerated Effects: Glitch at Scale」,原文链接https://medium.com/@konstantinzolotov/gpu-accelerated-effects-glitch-at-scale-e59216afd1e8,由Konstantin Zolotov发布于2025年11月9日。

几周前,我看到了Sina Samaki撰写的一篇关于使用Jetpack Compose制作故障效果的精彩文章。作为一个喜欢钻研底层技术的人,我看到了使用Android AGSL着色器重现这种效果并比较两种实现方式的绝佳机会。

在图形处理方面,选择合适的工具至关重要,因为很容易达到性能瓶颈,而扩展解决方案则变得困难。这里的情况是否如此呢?让我们一探究竟!

做好准备,我们将深入底层。

着色器(Shader)的本质

那么,着色器究竟是什么?

着色器是一种直接在 GPU 上执行的程序,并且可以并行执行。

着色器通常使用一种特殊的类 C 语言编写,在 Android Compose 中,这种语言是 AGSL——Android 图形着色语言。

我不会重复官方指南的内容,而是会简单介绍一下 GPU 以及一种新的着色器编程思维模型。

那么,它与 CPU 有什么区别呢? CPU 和 GPU 的主要区别基本上如下:

https://developer.nvidia.com/blog/cuda-refresher-reviewing-the-origins-of-gpu-computing/

CPU:

  • 更复杂
  • 专为执行大量不同任务的大型程序而设计
  • MIMD(多指令多数据流)

GPU:

  • 简单得多(没有分支预测,缓存更小)
  • 专为对各种数据执行完全相同操作的小型程序而设计
  • 更多核心 = 更高的并行性
  • SIMD(单指令多数据流)

当然,CPU 也有 SIMD 扩展,但规模远不及 GPU。

https://developer.nvidia.com/blog/cuda-refresher-reviewing-the-origins-of-gpu-computing/

GPU 非常适合对数百万像素执行相同的操作

这里存在一个非常重要的思维模型转变:以前你可以在画布的任意位置绘制,而现在你面对的是一幅图像,你可以对图像的任何部分进行采样(读取),但输出结果始终是一个像素。每个目标像素都会执行相同的着色器。这类似于一个纯函数,不会产生任何副作用,因此像素仅取决于其坐标和提供的 uniform 变量。

让我们开始实现吧,但首先,分析原始合成版本中的关键点,并将这些想法转化为着色器思维模型。

关键的动画驱动因素是步长。动画器会在 500 毫秒的周期内,将浮点数值从 10 递减到 0。步长状态为整数,由于浮点数会被转换为整数,因此共有 11 个步长。

1
2
3
4
5
6
7
8
9
10
11
12
13
var step by remember { mutableStateOf(0) }
LaunchedEffect(key) {
   Animatable(10f)
       .animateTo(
           targetValue = 0f,
           animationSpec = tween(
               durationMillis = 500,
               easing = LinearEasing,
           )
       ) {
           step = this.value.roundToInt()
       }
}

此外,还有一个名为强度的参数,它基于步长计算:

1
val intensity = step / 10f

因此,强度是一个数值序列 [1.0, 0.9, …, 0.0]。

下一个关键点是切片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
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 代码中,我仍然会使用步骤 + 强度,仅仅是为了尽可能地复现动画效果,并用于未来的性能测试。

思维模型转变: 着色器是逐像素执行的,但动画会将相同的变换应用于像素组(在本例中为切片)。为了在着色器中实现这一点,我们需要对整个切片进行完全相同的计算。还记得纯函数的相似性吗?它在这里非常有用,因为要得到相同的结果,我们只需要应用相同的参数!

具有相同变换的像素组就是一个切片:

1
2
3
4
5
6
7
8
9
10
11
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。因此,我们将以此为基础来移动切片:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 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,然后像原始实现一样乘以强度,并在发生位移时应用该因子。

缩放

屏幕空间缩放本质上是指相对于枢轴点(本例中为水平中心)调整像素采样坐标。由于我们是从源图像读取数据,因此实际上是移动了采样视口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 版本在这方面更灵活。

1
2
3
4
5
6
7
8
9
10
11
12
13
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 变量传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-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);
}

应用适当的随机性后,整个过程与原始行为非常接近:

你能猜出哪个是 compose,哪个是 AGSL 吗?

完整代码发布于此处

当然,在进行图形编程时,至少需要进行一些粗略的性能观察。为此,我将使用我的 Pixel 7,启用 HWUI 渲染图表,并稍作修改代码:使用 infiniteRepeatable 规范实现循环动画,并使用发布版本。Pixel 7 实际上非常适合这项任务,因为它并非高端设备,如果它在 Pixel 7 上有效,那么在性能更高的设备上也应该有效。

左侧为着色器,右侧为Compose

乍一看,这两个图表似乎很相似,但实际上存在一个问题:当前的实现方式隐式地限制了帧速率。动画会将浮点数值从 10 递减到 0,但状态更新时会使用四舍五入为整数的值。这意味着在 500 毫秒内只有 11 帧动画。这对于故障着色器来说非常方便,因为较低的帧速率也会增强故障效果。要消除这个限制,我们只需要将步长类型从整数 (Int) 更改为浮点数 (Float),并使用不进行四舍五入的可动画值即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-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 个切片

结论

Compose 非常适合用于复杂动画的原型设计,因为它可以使用熟悉的工具轻松实现。此外,值得一提的是,它适用于所有设备。

另一方面,着色器提供了性能更高、更稳定的渲染。在这种情况下,使用的切片数量无关紧要——无论是 20 个还是 500 个,计算量都没有区别,而纯 Compose 版本对此非常敏感,并且会随着切片数量的增加而线性增长。

此外,由于 AGSL 着色器只是在运行时临时编译的文本,因此理论上可以从后端更新这些动画。

但是,还有一个很大的问题:着色器从 Android 13 开始可用,因此根据 Android Studio 操作系统的发行情况,大约一半的设备将能够支持这种方法。当然,这种情况会随着时间的推移而改变,我希望我们能够利用着色器充分发挥图形编程的强大功能!

这是我的bento,如果你想联系我、聊天或讨论,欢迎来找我!

Comments