在Jetpack Compose中创建CRT屏幕效果

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

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

GraphicsLayer

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

kotlin 复制代码
val graphicsLayer = rememberGraphicsLayer()

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

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

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

添加扫描线

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

kotlin 复制代码
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 上绘制扫描线。

kotlin 复制代码
.drawBehind {
    layer {
        drawLayer(graphicsLayer)
        drawScanLines(alpha = 1f, blendMode = BlendMode.DstOut)
    }
}

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

构建模糊图层

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

kotlin 复制代码
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 列表来存储每个图层的数据。该列表的顺序也定义了它们的绘制顺序。我建议你尝试调整这些值和顺序,以获得所需的效果。但这是我目前使用的方法。

kotlin 复制代码
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 之间的随机浮点值来更新它。

kotlin 复制代码
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 循环中即可完成。可以调整延迟时间来控制闪烁的间隔频率。

kotlin 复制代码
modifier = modifier  
    .graphicsLayer {  
        translationX = shake.x  
        translationY = shake.y  
    }

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

整合所有功能

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

kotlin 复制代码
@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  
    )  
}

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

kotlin 复制代码
CRTBox {
    Text("GAME OVER")
}

Sweeper 更新

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

play.google.com/store/apps/...

apps.apple.com/us/app/swee...

感谢阅读,祝你好运!

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
2501_940094024 小时前
emu系列模拟器最新汉化版 安卓版 怀旧游戏模拟器全集附可运行游戏ROM
android·游戏·安卓·模拟器
下位子4 小时前
『OpenGL学习滤镜相机』- Day9: CameraX 基础集成
android·opengl
参宿四南河三6 小时前
Android Compose SideEffect(副作用)实例加倍详解
android·app
火柴就是我6 小时前
mmkv的 mmap 的理解
android
没有了遇见6 小时前
Android之直播宽高比和相机宽高比不支持后动态获取所支持的宽高比
android
shenshizhong7 小时前
揭开 kotlin 中协程的神秘面纱
android·kotlin
vivo高启强7 小时前
如何简单 hack agp 执行过程中的某个类
android
沐怡旸7 小时前
【底层机制】 Android ION内存分配器深度解析
android·面试
峰哥的Android进阶之路8 小时前
viewModel机制及原理总结
android jetpack