Snow Dialog Shader Tutorial
Android Jetpack Compose with runtimeShaders
Hello everyone! In this tutorial, I’ll show you how I created the snow-covered dialog effect. Overall, the effect can be broken down into three parts.
- Setting up the Compose code: This ensures the foundation is ready for the effect.
- Creating the shader for the snow buildup at the bottom of the dialog window: This is the core effect I’ll focus on in today’s tutorial.
- Adding falling snowflakes: For this, I found a ready-made shader on ShaderToy and optimized it slightly so it doesn’t heavily tax the phone’s performance. The third part will include a link to the original and a few notes on optimization.
The main focus of this tutorial is on creating the growing snowcap effect for the dialog.
Here’s the final result:
Before we dive in, I’d like to remind you that I don’t create tutorials for every effect. However, all effects are available on my GitHub. You can also find videos, announcements of new effects, and answers to your questions in my Telegram channel. I’d love to see you there!
Let’s start with a minimal screen setup in Compose for our effect. Essentially, it includes a background image and a button in the center that triggers the dialog.
It’s important to note that the dialog should not be from the Material library but rather the most basic AlertDialog in Compose.
Here’s the minimal setup:
@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)
)
}
}
}
}Finally you should get something similar to that:
Now we’re ready to start writing the shader! I’ll give a super short explanation of how to set it up and attach it to our dialog. For a more detailed introduction to runtime shaders and a beginner’s guide, you can check out my other tutorial.
The bare minimum needed to start working with the shader looks like this. In the dialog, we add a runtimeShader and assign it to the Column using graphicsLayer:
@Composable
private fun SnowedDialog(onDismiss: () -> Unit) {
// compile our shader from the string
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),
) {
//rest of code remains the same
...@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()For now it will show as our dialog as a blank white rectange:
Before we move on, let me try to explain the logic behind how we’ll achieve this effect as simply as possible. I’ve broken the algorithm into four conceptual steps:
- Find a function that will define the contour of the snow edge.
- Draw everything above this function on the y-axis and shift it vertically toward the bottom edge.
- Define a mask area, meaning that outside this area, the function is ignored entirely, and we simply draw what the shader receives from the system.
- Modify the mask on the sides and at the top to give the snow a more natural shape, leaving the bottom edge contour to be handled by our function.
Now let’s sequentially implement these steps in the shader. For simplicity, I’ve chosen a basic sine wave as the function for the snow edge. I’ve added some coefficients to compress it and reduce its amplitude:
float snowEdge = (sin(10.*uv.x)*0.5+0.5)*0.2;Next, we need to fill everything above this line with white, while ignoring everything below it
vec4 snow = step(uv.y, snowEdge)*vec4(1.0);By adding a constant to
uv.y, we can shift the entire function vertically.
Finally, we need to move the method closer to the bottom edge of the dialog and add a mask. For now, let’s use a simple rectangular area as the mask. We can define it by specifying each edge separately. This approach might seem a bit verbose, but it makes the code easier to understand:
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);
}After that adjustments we should have something like that as a result
This might not be the most appropriate moment to mention this, and perhaps it should have been done earlier, but it’s critically important to emphasize here. I want you to remember that we normalized the coordinates and shifted them so that zero is positioned exactly at the center of the canvas (i.e., the dialog). This was done with the following line:float2 uv = fragCoord/resolution — 0.5;
Get Alex Volkov’s stories in your inbox
Join Medium for free to get updates from this writer.
This is important to understand because now I want to reduce the amplitude of the sine wave at the center and increase it as we move farther away from the center to the left and right. Understanding how our coordinate system is set up makes it quite clear that we can achieve this by multiplying the function by the distance from the center along the x-axis. This way, the function will return zero exactly at the center and will grow as we move outward. As a result, we’ll achieve the following effect:
Now let’s define the top boundary of the mask using a function instead of a constant. For now, let’s use another sine wave, but with an interval equal to the width of the dialog.
float topBound = (sin(ratio*uv.x*0.8+2.)*0.5+0.5)*.2+0.3;We’ll add something similar for the left and right boundaries: I want them to also use sine waves, but this time running vertically.
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);Finally, we need to add one more constant for the bottom edge: this will be a time variable that we pass from Compose to the shader. In the shader, we’ll simply add the corresponding uniform and multiply the bottom edge function by it:
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);
}Do not forget to provide time from the composable:
@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 {
//Here providing time:
snowCapShader.setFloatUniform("time", time)
this.renderEffect = RenderEffect
.createRuntimeShaderEffect(snowCapShader, "image")
.asComposeRenderEffect()
}
.padding(16.dp),
) {
//Rest of the code stays the sameAnd we get something like that:
That’s pretty much everything I wanted to share about this effect! This is the core idea, and from here, you can experiment with various settings. For instance, I replaced the basic sine wave with a more chaotic version based on Perlin Noise.
But these are just details, and there can be endless variations in implementation. The key to creating shaders is to grasp the main idea behind them.
You can find my implementation in my repository, where you’ll also find a variety of helper methods for different enhancements. For example, there’s an entire collection of transitions (linear, cubic, exponential, etc.)
Adding snow is the final step, but the method for creating effects like snow, rain, stars, and similar elements deserves a separate tutorial. I’ll definitely cover that next time. For today, I’ll just mention that for this specific effect, I used a shader originally created back in 2013 on ShaderToy by Andrew Baldwin. Here’s the link: https://www.shadertoy.com/view/ldsGDn
I made a few simplifications since the original shader was quite heavy for mobile phones. Also it was needed some adoptations for AGSL. Here’s my version:
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; // Initialize alpha
float dof = 5.0 * sin(time * 0.1);
for (int i = 0; i < MAX_LAYERS; i++) {
if (i >= uLayers) break; // Break out of the loop if i exceeds uLayers
float fi = float(i);
vec2 q = uv * (1.0 + fi * uDepth);
// Adjust flake position with modulation and time
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);
// Rounded snowflake shape using a circular mask
float2 center = mod(q, 1.0) - 0.5 + 0.5 * r.xy;
float distanceToCenter = length(center); // Circular distance
float flakeRadius = 0.015 + 0.01 * r.z; // Vary radius slightly per flake
// Smoother edges with extended smoothstep
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); // White color for flakes
acc += flakeColor * intensity;
// Accumulate alpha with smooth transition
alpha += intensity;
}
// Normalize alpha to ensure it doesn’t exceed 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);
}And, of course, to make some snowflakes appear in front of the screen while others drift behind it, I had to use two layers with this shader. Here’s the final look of the composable function for the dialog:
@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)
)
}
}
}
}
}And final result:
Thanks for reading! If you find my experiments fun and my explanations helpful, feel free to join my Telegram channel or follow me on Twitter (X). Your support means a lot and keeps me motivated. Happy coding!









