本文译自「Snow Dialog Shader Tutorial」,原文链接medium.com/@off.mind.b...,由Alex Volkov发布于2024年12月27日。

大家好!在本教程中,我将向大家展示如何创建雪花覆盖的对话框效果。总的来说,该效果可以分为三个部分。
- 设置 Compose 代码:这确保了基础效果的实现。
- 创建对话框底部积雪的着色器:这是我在今天教程中重点讲解的核心效果。
- 添加飘落的雪花:为此,我在 ShaderToy 上找到了一个现成的着色器,并对其进行了一些优化,使其不会对手机性能造成太大负担。第三部分将包含原始着色器的链接以及一些优化说明。
本教程主要讲解如何为对话框创建不断增长的雪盖效果。
最终效果如下:

在深入探讨之前,我想提醒你,我并没有为所有效果创建教程。不过,所有效果都可以在我的GitHub 上找到。你还可以在我的 Telegram 频道
让我们从 Compose 中为效果设置一个最小页面开始。它包含一张背景图片和一个位于中心的按钮,用于触发对话框。
需要注意的是,对话框不应来自 Material 库,而应使用 Compose 中最基本的"AlertDialog"。
以下是最基本配置:
Kotlin
@Composable
fun SnowDialogScreen() {
var showDialog by remember { mutableStateOf(false) }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Image(
painter = painterResource(id = R.drawable.christmas_night),
contentDescription = "Sample Image",
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize()
) if (!showDialog) {
Button(onClick = { showDialog = true }) {
Text("Ho-ho-ho!")
}
} else {
SnowedDialog { showDialog = false }
}
}
}
@Composable
private fun SnowedDialog(onDismiss: () -> Unit) {
BasicAlertDialog(
onDismissRequest = { onDismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Column(
Modifier
.padding(horizontal = 16.dp)
.background(shape = MaterialTheme.shapes.large,
color = MaterialTheme.colorScheme.surface)
.padding(16.dp),
) {
Text("Merry Christmas!", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(10.dp))
Text("Happy New Year!")
Spacer(modifier = Modifier.height(26.dp))
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Text(
modifier = Modifier
.clickable { onDismiss() }
.padding(5.dp),
text = "Close",
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.primary)
)
}
}
}
}
最终,你应该得到类似这样的效果:

现在我们可以开始编写着色器了!我将简要介绍如何设置着色器并将其附加到对话框中。有关运行时着色器的更详细介绍和初学者指南,你可以查看我的另一个教程。
开始使用着色器所需的最低要求如下所示。在对话框中,我们添加一个 runtimeShader
,并使用 graphicsLayer
将其分配给 Column
:
kotlin
@Composable
private fun SnowedDialog(onDismiss: () -> Unit) {
// 从字符串编译我们的着色器
val snowCapShader = remember { RuntimeShader(snowCapShader) }
BasicAlertDialog(
onDismissRequest = { onDismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Column(
Modifier
.padding(horizontal = 16.dp)
.background(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface)
.onSizeChanged { size ->
// pass resolution
snowCapShader.setFloatUniform(
"resolution",
size.width.toFloat(),
size.height.toFloat()
)
}
.graphicsLayer {
// apply shader
this.renderEffect = RenderEffect
.createRuntimeShaderEffect(snowCapShader, "image")
.asComposeRenderEffect()
}
.padding(16.dp),
) {
// 其余代码保持不变
//...
@Language("agsl")
private val snowCapShader = """
uniform shader image;
uniform vec2 resolution;
vec4 main(float2 fragCoord) {
float2 uv = fragCoord / resolution - 0.5;
if(abs(uv.x)>0.5 || abs(uv.y)>0.5) {
return vec4(0.0);
}
float ratio = resolution.x / resolution.y;
uv.x *= ratio;
vec4 imageColor = image.eval(fragCoord);
return vec4(vec3(1.0), imageColor.a);
}
""".trimIndent()
目前,它将显示为一个空白矩形,就像我们的对话框一样:

在继续之前,让我先尽可能简单地解释一下实现此效果背后的逻辑。我将算法分为四个概念步骤:
- 找到一个函数来定义雪边缘的轮廓。
- 在 y 轴上绘制此函数上方的所有内容**,并将其垂直移向底部边缘。
- 定义一个遮罩区域,这意味着在此区域之外,函数将被完全忽略,我们只需绘制着色器从系统接收的内容即可。
- 修改侧面和顶部的遮罩,使雪的形状更自然,而底部边缘的轮廓则由我们的函数处理。

现在,让我们在着色器中按顺序实现这些步骤。为简单起见,我选择了一个基本的正弦波作为雪边缘的函数。我添加了一些系数来压缩它并降低其振幅:
glsl
float snowEdge = (sin(10.*uv.x)*0.5+0.5)*0.2;
接下来,我们需要用白色填充此线上方的所有内容,同时忽略其下方的所有内容。
glsl
vec4 snow = step(uv.y, snowEdge)*vec4(1.0);
通过向
uv.y
添加一个常量,我们可以垂直移动整个函数。
最后,我们需要将该方法移近对话框的底部边缘并添加一个遮罩。现在,我们使用一个简单的矩形区域作为遮罩。我们可以通过分别指定每个边缘来定义它。这种方法可能看起来有点冗长,但它使代码更容易理解:
glsl
vec4 main(float2 fragCoord) {
float2 uv = fragCoord / resolution - 0.5;
float ratio = resolution.x / resolution.y;
uv.x *= ratio;
float snowEdge = (sin(10.*uv.x)*0.5+0.5)*0.2;
snowEdge = step(uv.y-0.4, snowEdge);
float topBound = 0.3;
float leftBound = -.5*ratio;
float rightBound = .5*ratio;
float snowMask = 0.;
if(uv.x > leftBound && uv.x < rightBound && uv.y > topBound) {
snowMask = 1.0;
}
snowMask *= snowEdge;
vec4 imageColor = image.eval(fragCoord);
vec3 snowColor = vec3(1.0);
vec3 finalColor = mix(imageColor.rgb, snowColor, snowMask);
return vec4(finalColor, snowMask+imageColor.a);
}
调整后,我们应该得到类似这样的结果。

现在可能不是提及这一点的最佳时机,也许应该早点提及,但在这里强调这一点至关重要。请记住,我们对坐标进行了归一化,并对其进行了平移,使零点恰好位于画布(即对话框)的中心。具体操作如下:_float2 uv = fragCoord/resolution --- 0.5;_
理解这一点很重要,因为现在我想减小中心正弦波的振幅,并随着我们向左右两侧远离中心而增大振幅。理解坐标系的设置方式后,我们可以通过将函数乘以沿 x 轴距离中心的距离来实现这一点。这样,函数将在中心处恰好返回零点,并随着向外移动而增大。最终,我们将实现以下效果:

现在,让我们使用函数而不是常量来定义遮罩的顶部边界。现在,让我们使用另一个正弦波,但间隔等于对话框的宽度。
glsl
float topBound = (sin(ratio*uv.x*0.8+2.)*0.5+0.5)*.2+0.3;
我们将为左右边界添加类似的代码:我希望它们也使用正弦波,但这次是垂直运行的。
glsl
float leftBound = -.5*ratio+(sin(10.*uv.y)*0.5+0.5)*0.5*length(uv.y-topBound);
float rightBound = .5*ratio-(sin(10.*uv.y)*0.5+0.5)*0.4*length(uv.y-topBound);
最后,我们需要为底部边缘添加一个常量:这是一个时间变量,我们会将它从 Compose 传递给着色器。在着色器中,我们只需添加相应的 uniform
变量,并将底部边缘函数乘以它即可:
glsl
uniform shader image;
uniform vec2 resolution;
uniform float time; vec4 main(float2 fragCoord) {
float2 uv = fragCoord / resolution - 0.5;
float ratio = resolution.x / resolution.y;
uv.x *= ratio;
float adjustedTime = clamp(time * 0.3,0.0,1.0);
float snowEdge = (sin(10.*uv.x)*0.5+0.5)*0.2 * length(uv.x)*adjustedTime;
snowEdge = step(uv.y-0.5, snowEdge);
float topBound = (sin(ratio*uv.x*0.8+2.)*0.5+0.5)*.2+0.3;
float leftBound = -.5*ratio+(sin(10.*uv.y)*0.5+0.5)*0.5*length(uv.y-topBound);
float rightBound = .5*ratio-(sin(10.*uv.y)*0.5+0.5)*0.4*length(uv.y-topBound);
float snowMask = 0.;
if(uv.x > leftBound && uv.x < rightBound && uv.y > topBound) {
snowMask = 1.0;
}
snowMask *= snowEdge;
vec4 imageColor = image.eval(fragCoord);
vec3 snowColor = vec3(1.0);
vec3 finalColor = mix(imageColor.rgb, snowColor, snowMask);
return vec4(finalColor, snowMask+imageColor.a);
}
别忘了从composable中提供时间:
Kotlin
@Composable
private fun SnowedDialog(onDismiss: () -> Unit) {
val snowCapShader = remember { RuntimeShader(snowCapShader) }
var time by remember { mutableStateOf(0f) }
LaunchedEffect(null) {
while (true) {
delay(10)
time += 0.01f
}
}
BasicAlertDialog(
onDismissRequest = { onDismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Column(
Modifier
.padding(horizontal = 16.dp)
.background(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface)
.onSizeChanged { size ->
snowCapShader.setFloatUniform(
"resolution",
size.width.toFloat(),
size.height.toFloat()
)
}
.graphicsLayer {
// 这里提供时间:
snowCapShader.setFloatUniform("time", time)
this.renderEffect = RenderEffect
.createRuntimeShaderEffect(snowCapShader, "image")
.asComposeRenderEffect()
}
.padding(16.dp),
) {
// 其余代码保持不变
我们得到如下效果:

关于这个效果,我想分享的差不多就是这些了!这是核心思想,从这里开始,你可以尝试不同的设置。例如,我用一个基于 Perlin 噪声的更混乱的版本替换了基本的正弦波。
但这些只是细节,在实现上可以有无限的变化。创建着色器的关键在于掌握其背后的核心思想。
你可以在我的代码库中找到我的实现,其中还包含各种用于不同增强功能的辅助方法。例如,这里有一系列过渡效果(线性、三次、指数等)。
添加雪花是最后一步,但创建雪、雨、星星等效果的方法值得另开一个教程。下次我一定会讲解。今天,我只想提一下,为了实现这个特定的效果,我使用了 Andrew Baldwin 于 2013 年在 ShaderToy 上创建的着色器。链接如下:www.shadertoy.com/view/ldsGDn
由于原始着色器对于手机来说过于庞大,我做了一些简化。此外,它还需要一些适配 AGSL 的功能。这是我的版本:
glsl
uniform shader image;
uniform vec2 resolution;
uniform float time;
uniform int uLayers;
uniform float uDepth;
uniform float uSpeed;
const int MAX_LAYERS = 50;
const float WIDTH = 0.4;
vec2 NormalizeCoordinates(vec2 o, vec2 r) {
float2 uv = o / r - 0.5;
if (r.x > r.y) {
uv.x *= r.x / r.y;
} else {
uv.y *= r.y / r.x;
}
return uv;
}
vec4 GetImageTexture(vec2 p, vec2 pivot, vec2 r) {
if (r.x > r.y) {
p.x /= r.x / r.y;
} else {
p.y /= r.y / r.x;
}
p += pivot;
p *= r;
return image.eval(p);
}
vec4 main(float2 fragCoord) {
float2 uv = NormalizeCoordinates(fragCoord, resolution);
vec4 image = GetImageTexture(uv, vec2(0.5, 0.5), resolution);
const mat3 p = mat3(13.323122, 23.5112, 21.71123, 21.1212, 28.7312, 11.9312, 21.8112, 14.7212, 61.3934);
float ratio = resolution.y / resolution.x;
vec3 acc = vec3(0.0);
float alpha = 0.0; // 初始化 alpha
float dof = 5.0 * sin(time * 0.1);
for (int i = 0; i < MAX_LAYERS; i++) {
if (i >= uLayers) break; // 如果 i 超出 uLayers,则跳出循环
float fi = float(i);
vec2 q = uv * (1.0 + fi * uDepth);
// 通过调制和时间调整雪花位置
q -= vec2(q.y * (WIDTH * mod(fi * 7.238917, 1.0) - WIDTH * 0.5), uSpeed * time / (1.0 + fi * uDepth * 0.03)); vec3 n = vec3(floor(q), 31.189 + fi);
vec3 m = floor(n) * 0.00001 + fract(n);
vec3 mp = (31415.9 + m) / fract(p * m);
vec3 r = fract(mp);
// 使用圆形mask的圆形雪花形状
float2 center = mod(q, 1.0) - 0.5 + 0.5 * r.xy;
float distanceToCenter = length(center); // 圆周距离
float flakeRadius = 0.015 + 0.01 * r.z; // 每个薄片的半径略有不同
// 通过扩展的平滑步进实现更平滑的边缘
float intensity = smoothstep(flakeRadius + 0.015, flakeRadius, distanceToCenter) *
smoothstep(flakeRadius, flakeRadius - 0.015, distanceToCenter);
// Ensure flakes are white or transparent (prevent black color)
vec3 flakeColor = vec3(1.0); // 雪花要是白色
acc += flakeColor * intensity;
// 通过平滑过渡积累 alpha
alpha += intensity;
}
// 归一化 alpha 以确保其不超过 1.0
alpha = clamp(alpha, 0.0, 1.0);
vec3 finalColor = mix(image.rgb, acc, alpha);
if(uv.y < -0.5*ratio || uv.y > .5*ratio) {
finalColor = vec3(0.0);
alpha = 0.0;
}
return vec4(finalColor, alpha+image.a);
}
当然,为了让一些雪花出现在屏幕前方,而另一些雪花飘到屏幕后方,我必须在这个着色器中使用两层。这是对话框可组合函数的最终效果:
Kotlin
@Composable
private fun SnowedDialog(onDismiss: () -> Unit) {
val snowCapShader = remember { RuntimeShader(snowCapShader) }
val flakesShaderForeground = remember { RuntimeShader(snowShader) }
val flakesShaderBackground = remember { RuntimeShader(snowShader) }
var time by remember { mutableStateOf(0f) }
LaunchedEffect(null) {
while (true) {
delay(10)
time += 0.01f
}
}
flakesShaderForeground.setIntUniform("uLayers", 5)
flakesShaderForeground.setFloatUniform("uDepth", 0.15f)
flakesShaderForeground.setFloatUniform("uSpeed", 1.0f)
flakesShaderBackground.setIntUniform("uLayers", 10)
flakesShaderBackground.setFloatUniform("uDepth", 1.5f)
flakesShaderBackground.setFloatUniform("uSpeed", 0.8f)
BasicAlertDialog(
onDismissRequest = { onDismiss() },
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { size ->
flakesShaderForeground.setFloatUniform(
"resolution",
size.width.toFloat(),
size.height.toFloat()
)
}
.graphicsLayer {
flakesShaderForeground.setFloatUniform("time", time)
this.renderEffect = RenderEffect
.createRuntimeShaderEffect(flakesShaderForeground, "image")
.asComposeRenderEffect()
}
.padding(16.dp),
contentAlignment = Alignment.Center
) {
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { size ->
flakesShaderBackground.setFloatUniform(
"resolution",
size.width.toFloat(),
size.height.toFloat()
)
}
.graphicsLayer {
flakesShaderBackground.setFloatUniform("time", time)
this.renderEffect = RenderEffect
.createRuntimeShaderEffect(flakesShaderBackground, "image")
.asComposeRenderEffect()
}
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(color = Color.Black.copy(0.1f))
)
}
Column(
Modifier
.padding(horizontal = 16.dp)
.background(shape = MaterialTheme.shapes.large, color = MaterialTheme.colorScheme.surface)
.onSizeChanged { size ->
snowCapShader.setFloatUniform(
"resolution",
size.width.toFloat(),
size.height.toFloat()
)
}
.graphicsLayer {
snowCapShader.setFloatUniform("time", time)
this.renderEffect = RenderEffect
.createRuntimeShaderEffect(snowCapShader, "image")
.asComposeRenderEffect()
}
.padding(16.dp),
) {
Text("Merry Christmas!", style = MaterialTheme.typography.titleLarge)
Spacer(modifier = Modifier.height(10.dp))
Text("Happy New Year!")
Spacer(modifier = Modifier.height(26.dp))
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
Text(
modifier = Modifier
.clickable { onDismiss() }
.padding(5.dp),
text = "Close",
style = MaterialTheme.typography.bodyMedium.copy(color = MaterialTheme.colorScheme.primary)
)
}
}
}
}
}
最终结果:

感谢你的阅读!如果你觉得我的实验有趣且我的解释对你有帮助,欢迎加入我的Telegram频道或在Twitter (X)上关注我。你的支持意义重大,并激励着我。祝你撸码愉快!
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!