用Compose中的Shader实现一个雪花飘飘弹窗效果

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

大家好!在本教程中,我将向大家展示如何创建雪花覆盖的对话框效果。总的来说,该效果可以分为三个部分。

  1. 设置 Compose 代码:这确保了基础效果的实现。
  2. 创建对话框底部积雪的着色器:这是我在今天教程中重点讲解的核心效果。
  3. 添加飘落的雪花:为此,我在 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()

目前,它将显示为一个空白矩形,就像我们的对话框一样:

在继续之前,让我先尽可能简单地解释一下实现此效果背后的逻辑。我将算法分为四个概念步骤:

  1. 找到一个函数来定义雪边缘的轮廓。
  2. 在 y 轴上绘制此函数上方的所有内容**,并将其垂直移向底部边缘。
  3. 定义一个遮罩区域,这意味着在此区域之外,函数将被完全忽略,我们只需绘制着色器从系统接收的内容即可。
  4. 修改侧面和顶部的遮罩,使雪的形状更自然,而底部边缘的轮廓则由我们的函数处理。

现在,让我们在着色器中按顺序实现这些步骤。为简单起见,我选择了一个基本的正弦波作为雪边缘的函数。我添加了一些系数来压缩它并降低其振幅:

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)上关注我。你的支持意义重大,并激励着我。祝你撸码愉快!

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

保护原创,请勿转载!

相关推荐
iffy13 小时前
安卓录音方法
android
IT古董4 小时前
【第四章:大模型(LLM)】05.LLM实战: 实现GPT2-(6)贪婪编码,temperature及tok原理及实现
android·开发语言·kotlin
安卓机器7 小时前
安卓10.0系统修改定制化____系列 ROM解打包 修改 讲解 导读篇
android·安卓10系统修改
叽哥7 小时前
flutter学习第 14 节:动画与过渡效果
android·flutter·ios
小仙女喂得猪8 小时前
2025再读Android RecyclerView源码
android·android studio
BoomHe8 小时前
车载 XCU 的简单介绍
android
锅拌饭8 小时前
RecyclerView 缓存复用导致动画失效问题
android
程序员老刘8 小时前
操作系统“卡脖子”到底是个啥?
android·开源·操作系统
拭心8 小时前
一键生成 Android 适配不同分辨率尺寸的图片
android·开发语言·javascript