稀有猿诉

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

在Jetpack Compose中创建CRT屏幕效果

本文译自「Creating a CRT Screen Effect in Jetpack Compose」,原文链接https://www.sinasamaki.com/creating-a-crt-screen-effect-in-jetpack-compose/,由sinasamaki发布于2025年11月7日。

CRT 显示器具有独特而怀旧的外观:模糊的边缘、扫描线和轻微的色彩溢出。让我们尝试使用 GraphicsLayer 和一些巧妙的图层技巧在 Jetpack Compose 中重现这种效果。

GraphicsLayer

与上一篇文章一样,此效果的基础是 GraphicsLayer。它允许我们将内容绘制一次到屏幕外缓冲区。然后,我们可以以极低的性能开销多次使用不同的效果重新绘制它。

1
2
3
4
5
6
7
8
9
val graphicsLayer = rememberGraphicsLayer()

Box(Modifier.drawWithContent {
    graphicsLayer.record {
        this@drawWithContent.drawContent()
    }
}) {
    content()
}

一旦我们将内容记录到 graphicsLayer 中,就可以使用 drawLayer(graphicsLayer) 根据需要多次绘制它。

我喜欢在黑色背景上绘制色彩鲜艳、饱和度高的内容,并运用这种效果。以下是我们将应用此效果的基础可组合对象。

添加扫描线

为了模拟 CRT 显示器上的水平扫描线,我们将使用重复渐变。让我们将其放入一个扩展函数中,如下所示:

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
private fun DrawScope.drawScanLines(alpha: Float, blendMode: BlendMode) {
    val color = Colors.Black.copy(alpha = alpha)
    drawRect(
        brush = Brush.verticalGradient(
            0f to color,
            0.4f to color,
            0.4f to Colors.Transparent,
            1f to Colors.Transparent,
            tileMode = TileMode.Repeated,
            startY = 0f,
            endY = 10f,
        ),
        blendMode = blendMode
    )
    drawRect(
        brush = Brush.horizontalGradient(
            0f to color,
            0.1f to color,
            0.1f to Colors.Transparent,
            1f to Colors.Transparent,
            tileMode = TileMode.Repeated,
            startX = 0f,
            endX = 10f,
        ),
        blendMode = blendMode
    )
}

我们手动定义颜色停止点,以便在颜色之间形成清晰的边缘,然后将 tileMode 设置为 Repeated。这样,再加上较短的起点和终点,就能得到许多重复的平行线。

扩展函数还会接收我们所需的透明度和 BlendMode 参数。

然后,我们可以使用此函数在 graphicsLayer 上绘制扫描线。

1
2
3
4
5
6
.drawBehind {
    layer {
        drawLayer(graphicsLayer)
        drawScanLines(alpha = 1f, blendMode = BlendMode.DstOut)
    }
}

将混合模式设置为 DstOut 会从绘制的内容中“减去”我们的渐变,从而产生这种效果。

构建模糊图层

为了实现 CRT 屏幕常见的发光效果,我们将多次绘制 graphicsLayer 图层,每次绘制时分别设置不同的模糊半径、透明度和缩放比例。

1
2
3
4
5
6
7
8
9
val blurLayers = remember {
    listOf(
        Triple(1.dp, 0.2f, 1.02f to 1.03f),
        Triple(0.dp, .2f, 1f to 1f),
        Triple(1.dp, 0.9f, 1f to 1f),
        Triple(10.dp, 1f, 1f to 1f),
        Triple(40.dp, 1f, 1f to 1f),
    )
}

我们将使用一个 Triple 列表来存储每个图层的数据。该列表的顺序也定义了它们的绘制顺序。我建议你尝试调整这些值和顺序,以获得所需的效果。但这是我目前使用的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
blurLayers.forEach { (blur, alpha, scale) ->
    Box(
        Modifier
            .matchParentSize()
            .blur(blur, BlurredEdgeTreatment.Unbounded)
            .graphicsLayer {
                scaleX = scale.first
                scaleY = scale.second
                this.alpha = alpha
            }
            .drawBehind {
                layer {
                    drawLayer(graphicsLayer)
                    drawScanLines(alpha = 1f, blendMode = BlendMode.DstOut)
                }
            }
    )
}

然后,我们使用列表中的值绘制每个图层。在 drawBehind 修改器上方,我们将图层大小设置为与父图层匹配,并应用模糊、缩放和透明度。请记住将模糊设置为 Unbounded,使其超出包含它的可组合对象的边界。

屏幕抖动

最后,我们来添加屏幕抖动效果,以模拟 CRT 显示器特有的抖动。我们可以通过创建一个 Offset 对象,并用 -1 到 1 之间的随机浮点值来更新它。

1
2
3
4
5
6
7
8
9
10
11
var shake by remember { mutableStateOf(Offset.Zero) }

LaunchedEffect(Unit) {
    while (true) {
        shake = Offset(
            Random.nextInt(-1, 1) * Random.nextFloat(),
            Random.nextInt(-1, 1) * Random.nextFloat(),
        )
        delay(32)
    }
}

这里只需在一个 while 循环中即可完成。可以调整延迟时间来控制闪烁的间隔频率。

1
2
3
4
5
modifier = modifier
    .graphicsLayer {
        translationX = shake.x
        translationY = shake.y
    }

然后可以使用修饰符来应用此偏移量。

整合所有功能

让我们将所有这些功能组合成一个可轻松使用的组合。它会接收 content 参数以及 flickerDelay 参数,后者用于控制闪烁频率。

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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
@Composable
fun CRTBox(
    modifier: Modifier = Modifier,
    flickerDelay: Int = 32,
    content: @Composable () -> Unit,
) {
    var shake by remember { mutableStateOf(Offset.Zero) }

    LaunchedEffect(Unit) {
        while (flickerDelay > 0) {
            shake = Offset(
                Random.nextInt(-1, 1) * Random.nextFloat(),
                Random.nextInt(-1, 1) * Random.nextFloat(),
            )
            delay(flickerDelay.toLong())
        }
    }

    val graphicsLayer = rememberGraphicsLayer()

    Box(
        modifier = modifier
            .graphicsLayer {
                translationX = shake.x
                translationY = shake.y
            }
    ) {
        Box(Modifier.drawWithContent {
            graphicsLayer.record { this@drawWithContent.drawContent() }
        }) {
            content()
        }

        val blurLayers = remember {
            listOf(
                Triple(5.dp, .3f, 1.02f to 1.03f),
                Triple(0.dp, .8f, 1f to 1f),
                Triple(1.dp, .9f, 1f to 1f),
                Triple(10.dp, .6f, 1.001f to 1f),
                Triple(40.dp, .7f, 1f to 1f),
            )
        }

        blurLayers.forEach { (blur, alpha, scale) ->
            Box(
                Modifier
                    .matchParentSize()
                    .blur(blur, BlurredEdgeTreatment.Unbounded)
                    .graphicsLayer {
                        scaleX = scale.first
                        scaleY = scale.second
                        this.alpha = alpha
                    }
                    .drawBehind {
                        layer {
                            drawLayer(graphicsLayer)
                            drawScanLines(alpha = 1f, blendMode = BlendMode.DstOut)
                        }
                    }
            )
        }
    }}

private fun DrawScope.layer(
    bounds: Rect = size.toRect(),
    block: DrawScope.() -> Unit
) =
    drawIntoCanvas { canvas ->
        canvas.withSaveLayer(
            bounds = bounds,
            paint = Paint(),
        ) { block() }
    }

private fun DrawScope.drawScanLines(alpha: Float, blendMode: BlendMode) {
    val color = Colors.Black.copy(alpha = alpha)
    drawRect(
        brush = Brush.verticalGradient(
            0f to color,
            0.4f to color,
            0.4f to Colors.Transparent,
            1f to Colors.Transparent,
            tileMode = TileMode.Repeated,
            startY = 0f,
            endY = 10f,
        ),
        blendMode = blendMode
    )
    drawRect(
        brush = Brush.horizontalGradient(
            0f to color,
            0.1f to color,
            0.1f to Colors.Transparent,
            1f to Colors.Transparent,
            tileMode = TileMode.Repeated,
            startX = 0f,
            endX = 10f,
        ),
        blendMode = blendMode
    )
}

然后,你可以像使用其他可组合组件一样使用它:

1
2
3
CRTBox {
    Text("GAME OVER")
}

Sweeper 更新

如果你想查看实际效果,请查看最新的 Sweeper 更新,该更新使用 CRT 效果创建了一个令人毛骨悚然的万圣节主题。

https://play.google.com/store/apps/details?id=com.sinasamaki.chroma.sweeper

https://apps.apple.com/us/app/sweeper-by-sinasamaki/id6752220495

感谢阅读,祝你好运!

Comments