Sitemap
Alex Volkov

Alex Volkov

Principal Software Engineer with passion to shaders

Burn After Reading

Android Jetpack Compose with runtimeShaders — Halloween edition

22 min readOct 17, 2026

--

Press enter or click to view image in full size

Hi everyone!

Last Christmas I made a snow-covered dialog, so it would be unfair to let Halloween pass without a shader :) This time I want to show you an effect I wanted to build for a long time: a note that burns away right from under your finger. You hold the card, the paper catches fire at the touch point, the flame crawls to the edges, chars the paper, leaves a glowing ember line and a few sparks behind — and the message is gone. Burn after reading!

What I like about this effect is that, as usual, it looks complex and expensive, while in reality it is built from just a few simple ideas. And one of them is something I have owed you for a long time. In the Snow Dialog tutorial I mentioned that I replaced a plain sine wave with noise, but I never explained what noise actually is and why shader programmers use it literally everywhere. Today is the day.

As usual, I’ve broken everything down into three parts:

  • Compose setup: a card, a long press and a progress animation. Nothing fancy, but there are a couple of details worth mentioning.
  • The shader itself: we will go step by step from a perfectly round hole to a living, burning edge. This is the core of today’s tutorial.
  • Small details: flicker, halo, sparks, heat shimmer and the right easing. As always, they create 90% of the final look.

In the end, we’ll get something like this:

final result

Before we get started, just a quick note — I don’t make a tutorial for every effect I create, but you can find all of them on my GitHub. You’ll also find videos, announcements about new effects, and answers to your questions in my Telegram channel. I’d be happy to see you there! If you are really new to shaders and have no clue what is going on here, feel free to read my book for very beginners.

Compose setup

Let’s start with the minimal setup. We need a dark background and a card that we are going to burn.

@Composable
fun BurnAfterReadingScreen() {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF0E0B1E)),
contentAlignment = Alignment.Center
) {
SecretNote()
}
}
@Composable
fun SecretNote(modifier: Modifier = Modifier) {
Column(
modifier = modifier
.width(320.dp)
.background(Color(0xFFF2E6CF), RoundedCornerShape(18.dp))
.padding(20.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text("SECRET NOTE", fontSize = 11.sp, letterSpacing = 2.sp, color = Faded)
Text("23:47", fontSize = 11.sp, color = Faded)
}
Spacer(Modifier.height(20.dp))
Text(
text = "Meet me at the old lighthouse at midnight. Come alone.",
fontFamily = Caveat,
fontSize = 27.sp,
color = Ink
)
Text(
text = "— A.",
modifier = Modifier.align(Alignment.End),
fontFamily = Caveat,
fontSize = 24.sp,
color = Ink
)
Text("hold to burn", fontSize = 10.sp, color = Faded)
}
}
private val Ink = Color(0xFF2A2018)
private val Faded = Ink.copy(alpha = 0.55f)
private val Caveat = FontFamily(Font(R.font.caveat_bold))

Caveat here is just a handwritten font from Google Fonts that I put into res/font. Use whatever you like — the shader doesn’t care what is drawn inside the card, and that is exactly the point.

Press enter or click to view image in full size
nothing special, just a note :)

Now let’s prepare everything the shader will need. Here is the same screen, but with all the plumbing in place:

@Composable
fun BurnAfterReadingScreen() {
val shader = remember { RuntimeShader(burnShader) }
val progress = remember { Animatable(0f) }
var touch by remember { mutableStateOf(Offset.Zero) }
var burnt by remember { mutableStateOf(false) }
val time = rememberShaderTime()
val scope = rememberCoroutineScope()
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF0E0B1E)),
contentAlignment = Alignment.Center
) {
if (!burnt) {
SecretNote(
modifier = Modifier
.onSizeChanged { size ->
shader.setFloatUniform(
"resolution",
size.width.toFloat(),
size.height.toFloat()
)
}
.pointerInput(Unit) {
detectTapGestures(
onLongPress = { offset ->
if (!progress.isRunning) {
touch = offset // <-- local to the note, in px
scope.launch {
progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 2600)
)
burnt = true
}
}
}
)
}
.graphicsLayer {
shader.setFloatUniform("touch", touch.x, touch.y)
shader.setFloatUniform("progress", progress.value)
shader.setFloatUniform("time", time.value)
renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "image")
.asComposeRenderEffect()
}
)
}
}
}

shader — the shader itself, which we will write in the second part.

progress — the main parameter of the whole effect: 0 means the note is untouched, 1 means it is completely gone. I use Animatable instead of animateFloatAsState here because I want to start the animation right from a gesture callback and know exactly when it ends.

touch — the point where the fire starts. The nice thing is that pointerInput gives us coordinates local to the same composable the shader is attached to, so this time we don’t need any conversions at all. If you remember the Metaballs tutorial, you know how painful those conversions can be :)

time — we need it to animate flicker and sparks. rememberShaderTime is a tiny helper, I’ll show it below.

burnt — once the animation is finished, we remove the note from the composition. Otherwise an invisible card would still sit there and catch all the touches. To play it again, just set burnt back to false and call progress.snapTo(0f).

One more thing worth noticing: the modifier from outside is applied first, and the background is applied inside SecretNote, after graphicsLayer. That’s important. If the background comes before the graphics layer, the shader simply won’t get it as an input.

And here is the time helper:

@Composable
fun rememberShaderTime(): State<Float> {
val time = remember { mutableFloatStateOf(0f) }
LaunchedEffect(Unit) {
val start = withFrameNanos { it }
while (true) {
withFrameNanos { frameTime ->
time.floatValue = (frameTime - start) / 1_000_000_000f
}
}
}
return time
}

withFrameNanos suspends until the next frame, so the time is updated exactly once per frame and we don’t need the delay(10) loop that I used back in the Snow Dialog. And because we read time.value inside the graphicsLayer block, Compose only invalidates the layer — there is no recomposition on every frame.

Here is how all the pieces are connected:

Press enter or click to view image in full size
green is Compose, pink is the shader

The shader

As always, let’s start with the boilerplate. The shader below does nothing, it just returns its input:

@Language("AGSL")
private val burnShader = """
uniform shader image;
uniform float2 resolution;
uniform float2 touch;
uniform float progress;
uniform float time;
vec4 main(float2 fragCoord) {
return image.eval(fragCoord);
}
""".trimIndent()

If you run it now, you should see exactly the same note as before. And that’s a good sign!

Declare all the uniforms right away, even if you don’t use them yet. setFloatUniform throws an exception if it can’t find a uniform with that name in the shader, and the stack trace is not very friendly.

Next, we need a coordinate system. I normalize fragCoord, shift it so that zero is right in the center of the card, and fix the aspect ratio. The only new thing here is that we have to do exactly the same with the touch point, otherwise uv and touch would live in two different worlds:

float ratio = resolution.x / resolution.y;
vec2 uv = fragCoord / resolution - 0.5;
uv.x *= ratio;
vec2 t = touch / resolution - 0.5; // the touch point, in exactly the same space
t.x *= ratio;

Now let’s draw the simplest possible version of a burn: a circle that grows from the touch point. Everything closer to the touch than the radius is burnt, everything else is still paper. And to see where the touch actually is, I’ll draw a small red helper point:

vec4 main(float2 fragCoord) {
// ... uv and t from above
vec2 halfSize = vec2(0.5 * ratio, 0.5);
float dist = length(uv - t);
float maxDist = length(abs(t) + halfSize);
float radius = progress * maxDist;
float alpha = step(radius, dist);
vec4 color = image.eval(fragCoord) * alpha;
// red helper point, we will remove it later
float helper = 1.0 - smoothstep(0.022, 0.026, dist);
return mix(color, vec4(0.9, 0.12, 0.12, 1.0), helper);
}

Wait, what is maxDist? We want progress = 1 to mean “everything is burnt”, so the radius must reach the farthest corner of the card, no matter where the user touched it. And here is a nice little trick. Since our coordinate system is centered, the farthest corner is always on the opposite side from the touch. So along each axis we first go from the touch to the center (abs(t)), and then from the center to the edge (halfSize). No if s, no loops over four corners — just one line:

Press enter or click to view image in full size
distance to the farthest corner, without a single condition
Press enter or click to view image in full size
it works, but it looks like a cookie cutter, not like fire

It works! But honestly, it looks more like a hole punch than fire. Real fire never burns in a perfect circle: paper isn’t uniform, some fibers burn faster, some slower. So we need some controlled chaos. And that’s exactly what noise is.

A few words about noise

Noise is probably the most used tool in shader programming, and the idea behind it is surprisingly simple. I’ll build it in three steps.

Step 1: hash. We need a function that takes a coordinate and returns a “random” number from 0 to 1. Shaders don’t have random(), but they have math, so we cheat.

float Hash21(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}

Take a sine of some number, multiply it by an even bigger number and keep only the fractional part — the result jumps so chaotically that it looks random. But it is deterministic: the same input always gives the same output, and that is exactly what we need. Call it for every cell of a grid, and you get TV static (the first image below).

Step 2: value noise. Static is way too chaotic for anything natural. So let’s put random values only into the corners of a grid, and smoothly interpolate between them inside each cell:

float ValueNoise(vec2 p) {
vec2 i = floor(p); // which cell we are in
vec2 f = fract(p); // where we are inside that cell
// random values in the four corners of the cell
float a = Hash21(i);
float b = Hash21(i + vec2(1.0, 0.0));
float c = Hash21(i + vec2(0.0, 1.0));
float d = Hash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f); // smoothstep curve
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}

The u = f * f * (3.0 - 2.0 * f) line is just the smoothstep curve written by hand. With linear interpolation you would see sharp creases on the cell borders, and this smooth curve hides them. The result looks like soft blurry blobs (the second image).

Step 3: fBm (fractal Brownian motion — a wise name for a simple idea). The blobs are still too soft. Nature has details at all scales: big shapes, smaller shapes on top of them, and even smaller ones on top of those. So let’s sum several layers of noise, where each next layer has twice the frequency and half the amplitude:

float Fbm(vec2 p) {
float sum = 0.0;
float amp = 0.5;
for (int i = 0; i < 5; i++) {
sum += ValueNoise(p) * amp;
p *= 2.0; // twice the frequency
amp *= 0.5; // half the amplitude
}
return sum;
}

Each layer is called an octave, like in music. Five octaves give us that cloudy, organic texture (the third image), which works for clouds, smoke, terrain… and burning paper.

Press enter or click to view image in full size
Hash21 → ValueNoise → Fbm

Fbm returns values roughly from 0 to 1, but it almost never reaches the extremes — most of the values gather around 0.5. Keep that in mind when you tune the amplitude.

Deform the distance, not the shape

And now the most important trick of today’s tutorial. The funny thing is that it’s just one line, though it took me a while to see it this way. We don’t touch the circle at all. Instead, we add noise to the distance itself:

const float NOISE_AMP = 0.45;
// float field = dist; - was like that
float field = dist + Fbm(uv * 4.0) * NOISE_AMP;
float maxReach = length(abs(t) + halfSize) + NOISE_AMP;
float front = progress * maxReach;
float alpha = step(front, field);

Think about it like this. Before, every point had a distance to the touch, and all points at the same distance burned at the same moment — that’s why we got a circle. Now some points are “farther” than they really are, because the noise pushed them away, and some are almost untouched. It’s like walking over rough terrain: the same distance takes a different time depending on the path. I call this value the burn field, and you can see it in the left image: dark areas burn first, bright ones burn last.

Get Alex Volkov’s stories in your inbox

Join Medium for free to get updates from this writer.

Of course, maxReach now has to include the noise amplitude too, otherwise some bright spots would never burn, even at progress = 1.

Press enter or click to view image in full size
left: the burn field, right: the same step(), but applied to the field

We didn’t define shapes inside the shader, we deformed the coordinate system.

Ember and char

We already have a hole with a nice ragged edge. Now let’s make it burn! First, let’s introduce one more variable — the signed distance to the burning front:

const float EMBER_WIDTH = 0.03;
const float CHAR_WIDTH = 0.16;
float front = mix(-CHAR_WIDTH, maxReach, progress);
float d = field - front;

If d < 0, the pixel is already burnt. If d = 0, it is exactly on the edge of the fire. If d > 0, it is still paper, and the bigger d is, the farther away the fire is. From now on, the whole look of the effect is just a question of what we draw at different values of d. Here it is as a profile:

Press enter or click to view image in full size
everything the shader draws is just a function of d

You might notice that the front now starts not at zero, but at -CHAR_WIDTH. That’s because the char band lives in front of the fire, so at progress = 0 it would already be visible as a dark spot under the finger. Starting a bit behind fixes it.

So we need three masks:

float alpha   = smoothstep(0.0, 0.006, d);             // 0 in the hole, 1 on the paper
float ember = 1.0 - smoothstep(0.0, EMBER_WIDTH, d); // thin bright line right at the front
float charred = 1.0 - smoothstep(0.0, CHAR_WIDTH, d); // wide dark band in front of the fire

alpha cuts the hole (I use a tiny smoothstep instead of step to get an antialiased edge). ember is a thin bright band right at the front. charred is a wider band ahead of the fire, where the paper is already brown and black, but not burnt yet. All three are just smoothsteps with different widths.

And notice that charred is used twice: first to pick the char color itself (from brown to almost black), and then to mix it with the paper. So right in front of the flame the paper is black, a bit further it’s brown, and then it fades back to normal paper:

vec4 paper = image.eval(fragCoord);
vec3 charColor = mix(vec3(0.55, 0.33, 0.14), vec3(0.05, 0.03, 0.02), charred);
vec3 col = mix(paper.rgb, charColor * paper.a, charred);
vec3 emberColor = vec3(1.0, 0.5, 0.0);
col += emberColor * ember * paper.a;
return vec4(clamp(col, 0.0, 1.0), paper.a) * alpha;

paper.rgb is premultiplied by alpha, that’s why I multiply charColor by paper.a as well — otherwise the rounded corners of the card get dark, ugly edges. And don’t forget, as I said in the Navigation 3 tutorial, in AGSL you need to multiply RGB by alpha to get correct transparency. That’s what the last line does.

Now, the color of the ember. My first attempt was a plain RGB orange, and it looked… fine. Flat. Like somebody circled the hole with an orange marker (the left image below).

And here comes the trick from the Colorful Toggle again: HSV. Remember, in HSV the brightness lives in its own channel, so we can easily push it above 1:

vec3 HSVtoRGB(vec3 c) {
vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0);
return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y);
}
// hue 0.07 is orange, saturation 0.9, brightness 2.2 (!)
vec3 emberColor = HSVtoRGB(vec3(0.07, 0.9, 2.2));

HSVtoRGB(vec3(0.07, 0.9, 2.2)) gives us roughly (2.2, 1.05, 0.22). We clamp the color before returning it, so where the ember is strong — right in the middle of the band — both red and green hit the ceiling, and we get a hot yellow core. A bit to the side the ember gets weaker, the color drops below the ceiling and turns orange, and then red. So the clamp, which is usually our enemy, here gives us a realistic fire gradient for free!

Press enter or click to view image in full size
left: RGB orange, right: HSV with brightness 2.2 — the yellow core appears by itself

Small details

Technically, the effect is ready. But, as I promised, there are a few small things that, as usual, make the biggest difference.

Flicker. Real embers are never uniformly bright. So let’s modulate the ember with the same noise, but with a high frequency and moving with time:

float flicker = 0.7 + 0.6 * ValueNoise(uv * 25.0 + vec2(0.0, time * 4.0));
vec3 emberColor = HSVtoRGB(vec3(0.07, 0.9, 2.2)) * flicker;

Halo. Burning paper glows, and this light spills into the hole. It’s the same Gaussian falloff we used for the rings in the Colorful Toggle — exp(-d² · k) — but only on the burnt side, (1.0 - alpha):

vec4 result = vec4(clamp(col, 0.0, 1.0), paper.a) * alpha; // instead of return
vec2 toBorder = halfSize - abs(uv);
float borderFade = smoothstep(0.0, 0.08, min(toBorder.x, toBorder.y));
float halo = exp(-d * d * 400.0) * (1.0 - alpha) * paper.a * borderFade;
result += vec4(HSVtoRGB(vec3(0.05, 1.0, 1.0)), 1.0) * halo * 0.55;

Here is an interesting thing: look at paper.a. We never actually destroy the image — the shader still receives the whole note as input, we only decide what to show. So inside the hole paper.a still tells us “there used to be paper here”, and the halo never leaks outside of the card shape. Nice and free.

There is one gotcha though. The render effect is clipped by the bounds of the layer, so near the edges of the card the halo is simply cut off, and you see an ugly straight line. That’s what borderFade is for: it fades the halo out near the borders, so the cut is never visible.

If you want the glow and sparks to fly outside of the card, apply the shader to a bigger parent Box with some padding instead. I kept it simple here.

Sparks. This is the same idea as the snowflakes, just upside down. We split the space into a grid, use Hash21 to decide whether a cell has a spark and where exactly inside the cell it sits, and slide the whole grid up with time. Then we keep the sparks only in a narrow zone right behind the front:

vec2 sp = uv * 14.0 + vec2(0.0, time * 1.2);              // a grid that slides up
vec2 cell = floor(sp);
vec2 jitter = vec2(Hash21(cell + 1.7), Hash21(cell + 9.3)) - 0.5;
vec2 q = fract(sp) - 0.5 - jitter * 0.6; // random spot inside the cell
float spark = exp(-dot(q, q) * 120.0) * step(0.75, Hash21(cell)); // ~25% of the cells
float sparkZone = (1.0 - smoothstep(0.0, 0.25, -d)) * (1.0 - alpha) * paper.a;
result += vec4(1.0, 0.75, 0.4, 1.0) * spark * sparkZone;

Heat shimmer. Hot air bends light, so the paper right next to the fire should wobble a little. We simply shift the coordinates before sampling the image — that’s why the burn field is calculated before image.eval, it doesn’t depend on the image at all. And to make the shimmer appear and disappear smoothly, I used the same trick as for the toggle squish: sin(progress * PI) is zero at the start and at the end, and peaks in the middle.

float heat = exp(-d * d * 60.0) * sin(progress * PI);
vec2 wobble = vec2(
ValueNoise(uv * 10.0 + vec2(0.0, time * 3.0)),
ValueNoise(uv * 10.0 + vec2(5.2, time * 3.0))) - 0.5;
vec4 paper = image.eval(fragCoord + wobble * heat * 12.0);

Easing. And the last one. With linear progress the fire moves like a windshield wiper — the same speed from the first frame to the last. Real paper needs a moment to catch fire, and then the flame speeds up. The built-in EaseIn from Compose does exactly that:

progress.animateTo(
targetValue = 1f,
animationSpec = tween(durationMillis = 2600, easing = EaseIn)
)
Press enter or click to view image in full size
blue: EaseIn for progress, red: sin(πt) for the heat shimmer

Finally, when progress gets close to 1, the halo and sparks are still visible around the last tiny pieces of paper, so I fade them out during the last 15% — that’s the fadeOut you’ll see in the final code.

Here is the difference all these small things make:

Press enter or click to view image in full size
left: just ember and char, right: + flicker, halo, sparks and heat shimmer

And the whole shader together:

@Language("AGSL")
private val burnShader = """
uniform shader image;
uniform float2 resolution;
uniform float2 touch;
uniform float progress;
uniform float time;
const float PI = 3.14159265;
const float NOISE_AMP = 0.45;
const float EMBER_WIDTH = 0.03;
const float CHAR_WIDTH = 0.16;
float Hash21(vec2 p) {
return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453);
}
float ValueNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
float a = Hash21(i);
float b = Hash21(i + vec2(1.0, 0.0));
float c = Hash21(i + vec2(0.0, 1.0));
float d = Hash21(i + vec2(1.0, 1.0));
vec2 u = f * f * (3.0 - 2.0 * f);
return mix(mix(a, b, u.x), mix(c, d, u.x), u.y);
}
float Fbm(vec2 p) {
float sum = 0.0;
float amp = 0.5;
for (int i = 0; i < 5; i++) {
sum += ValueNoise(p) * amp;
p *= 2.0;
amp *= 0.5;
}
return sum;
}
vec3 HSVtoRGB(vec3 c) {
vec3 p = abs(fract(c.xxx + vec3(0.0, 2.0/3.0, 1.0/3.0)) * 6.0 - 3.0);
return c.z * mix(vec3(1.0), clamp(p - 1.0, 0.0, 1.0), c.y);
}
vec4 main(float2 fragCoord) {
float ratio = resolution.x / resolution.y;
vec2 uv = fragCoord / resolution - 0.5;
uv.x *= ratio;
vec2 t = touch / resolution - 0.5;
t.x *= ratio;
vec2 halfSize = vec2(0.5 * ratio, 0.5);
// burn field: distance to the touch, deformed by noise
float field = length(uv - t) + Fbm(uv * 4.0) * NOISE_AMP;
float maxReach = length(abs(t) + halfSize) + NOISE_AMP;
float front = mix(-CHAR_WIDTH, maxReach, progress);
float d = field - front;
// heat shimmer
float heat = exp(-d * d * 60.0) * sin(progress * PI);
vec2 wobble = vec2(
ValueNoise(uv * 10.0 + vec2(0.0, time * 3.0)),
ValueNoise(uv * 10.0 + vec2(5.2, time * 3.0))) - 0.5;
vec4 paper = image.eval(fragCoord + wobble * heat * 12.0);
// hole, ember and char
float alpha = smoothstep(0.0, 0.006, d);
float ember = 1.0 - smoothstep(0.0, EMBER_WIDTH, d);
float charred = 1.0 - smoothstep(0.0, CHAR_WIDTH, d);
vec3 charColor = mix(vec3(0.55, 0.33, 0.14), vec3(0.05, 0.03, 0.02), charred);
vec3 col = mix(paper.rgb, charColor * paper.a, charred);
float flicker = 0.7 + 0.6 * ValueNoise(uv * 25.0 + vec2(0.0, time * 4.0));
vec3 emberColor = HSVtoRGB(vec3(0.07, 0.9, 2.2)) * flicker;
col += emberColor * ember * paper.a;
vec4 result = vec4(clamp(col, 0.0, 1.0), paper.a) * alpha;
// halo and sparks inside the hole
float fadeOut = 1.0 - smoothstep(0.85, 1.0, progress);
vec2 toBorder = halfSize - abs(uv);
float borderFade = smoothstep(0.0, 0.08, min(toBorder.x, toBorder.y));
float halo = exp(-d * d * 400.0) * (1.0 - alpha) * paper.a * borderFade * fadeOut;
result += vec4(HSVtoRGB(vec3(0.05, 1.0, 1.0)), 1.0) * halo * 0.55;
vec2 sp = uv * 14.0 + vec2(0.0, time * 1.2);
vec2 cell = floor(sp);
vec2 jitter = vec2(Hash21(cell + 1.7), Hash21(cell + 9.3)) - 0.5;
vec2 q = fract(sp) - 0.5 - jitter * 0.6;
float spark = exp(-dot(q, q) * 120.0) * step(0.75, Hash21(cell));
float sparkZone = (1.0 - smoothstep(0.0, 0.25, -d)) * (1.0 - alpha) * paper.a * fadeOut;
result += vec4(1.0, 0.75, 0.4, 1.0) * spark * sparkZone;
return clamp(result, 0.0, 1.0);
}
""".trimIndent()

The mental model is simple: the distance to the touch, deformed by fBm, gives the fire its shape; progress moves the front; the signed distance d decides what every pixel is — hole, ember, char or paper; and HSV with brightness above 1 makes it glow. Everything else is decoration.

Tweaking the numbers here is honestly the most satisfying part, so I really don’t want to steal that from you. Try a lower NOISE_AMP for a calmer burn, or a higher frequency inside Fbm(uv * 4.0) for thin, crispy paper. And if you replace the cream paper with a photo, you get a burning polaroid for free.

Press enter or click to view image in full size
final look

One more thing: smoke

Honestly, I was going to stop here. But when I ran it on a device, something was clearly missing — paper doesn’t burn without smoke! So here is one more small layer. I won’t go through it line by line: there is nothing new in it, just the same tricks used once again. But it makes the whole effect much more immersive.

The only tricky part is that smoke has to rise above the note, and, as we already know, the render effect is clipped by the bounds of the layer. So the smoke can’t live inside burnShader. Instead, it’s a separate layer on top of the note that draws its own shader through a ShaderBrush. To know where the note is, we simply remember its position and size:

val smokeShader = remember { RuntimeShader(burnSmokeShader) }
val smokeBrush = remember { ShaderBrush(smokeShader) }
val smokeFade = remember { Animatable(0f) }
var cardOrigin by remember { mutableStateOf(Offset.Zero) }
var cardSize by remember { mutableStateOf(Size.Zero) }
val smokeVisible by remember {
derivedStateOf { progress.value > 0f && smokeFade.value < 1f }
}
// SecretNote gets one more modifier, the rest stays the same
.onGloballyPositioned { coords ->
cardOrigin = coords.positionInParent()
cardSize = coords.size.toSize()
}
// in onLongPress, once the note is gone
burnt = true
smokeFade.animateTo(1f, tween(durationMillis = 1200))
// right after SecretNote, inside the same Box
if (smokeVisible) {
Box(
modifier = Modifier
.matchParentSize()
.drawBehind {
smokeShader.setFloatUniform("cardOrigin", cardOrigin.x, cardOrigin.y)
smokeShader.setFloatUniform("cardSize", cardSize.width, cardSize.height)
smokeShader.setFloatUniform("touch", touch.x, touch.y)
smokeShader.setFloatUniform("progress", progress.value + smokeFade.value * 0.5f)
smokeShader.setFloatUniform("fade", smokeFade.value)
smokeShader.setFloatUniform("time", time.value)
drawRect(smokeBrush)
}
)
}

Notice the progress + smokeFade * 0.5f part: after the note is gone, the fire keeps “burning” a bit longer in the smoke shader, so the smoke detaches from the note and drifts away instead of just disappearing.

And the shader. In short, for every pixel around and above the note we look down and ask: was there fire below me a moment ago? We ask it for several puffs of different age. Older puffs look further down and further back in time ( progress - age * LAG), and they are wider, because smoke spreads while it rises. The burn front is calculated exactly like in the note shader, so the smoke always comes out of the real ember line. Then the good old fBm tears it into wisps that crawl up, and the youngest smoke, right above the fire, gets a warm tint from the ember:

@Language("AGSL")
private val burnSmokeShader = """
uniform float2 cardOrigin; // note position inside the screen box, px
uniform float2 cardSize; // note size, px
uniform float2 touch; // long-press point, local to the note, px
uniform float progress; // burn progress, the same as for the note
uniform float fade; // 0..1, fades the smoke out after the note is gone
uniform float time;
const float NOISE_AMP = 0.45;
const float CHAR_WIDTH = 0.16;
const int PUFFS = 8;
const float RISE = 0.9; // how high the oldest smoke gets, in note heights
const float LAG = 0.2; // how much older the oldest smoke is, in progress units
// Hash21 and ValueNoise are exactly the same as in burnShader
// 3 octaves are enough for smoke, it is blurry anyway
float SmokeFbm(vec2 p) {
float sum = 0.0;
float amp = 0.5;
for (int i = 0; i < 3; i++) {
sum += ValueNoise(p) * amp;
p *= 2.0;
amp *= 0.5;
}
return sum;
}
// the same burn front as in the note shader, for any point of the note at any progress
float BurnDistance(vec2 notePx, float prog) {
float ratio = cardSize.x / cardSize.y;
vec2 uv = notePx / cardSize - 0.5;
uv.x *= ratio;
vec2 t = touch / cardSize - 0.5;
t.x *= ratio;
float field = length(uv - t) + SmokeFbm(uv * 4.0) * NOISE_AMP;
float maxReach = length(abs(t) + vec2(0.5 * ratio, 0.5)) + NOISE_AMP;
return field - mix(-CHAR_WIDTH, maxReach, prog);
}
vec4 main(float2 fragCoord) {
vec2 local = fragCoord - cardOrigin;
float h = cardSize.y;
// smoke lives only around and above the note, skip everything else
if (local.x < -0.4 * h || local.x > cardSize.x + 0.4 * h ||
local.y < -1.4 * h || local.y > cardSize.y) {
return vec4(0.0);
}
vec2 p = local / h; // 1.0 = note height
float jitter = ValueNoise(p * 9.0 + vec2(0.0, time * 2.0)); // hides the steps between puffs
float density = 0.0;
float warmth = 0.0;
for (int i = 0; i < PUFFS; i++) {
float age = (float(i) + jitter) / float(PUFFS); // 0 = just born, 1 = the oldest
float sway = ValueNoise(vec2(p.y * 2.5 - time * 0.8, float(i) * 3.7)) - 0.5;
float drift = sway * 0.35 * age; // smoke sways more while it rises
vec2 src = (p + vec2(drift, age * RISE)) * h; // where this puff came from, px
if (src.x < 0.0 || src.y < 0.0 || src.x > cardSize.x || src.y > cardSize.y) continue;
// soft edges of the note, otherwise the smoke gets cut by straight lines
float edge = min(min(src.x, cardSize.x - src.x), min(src.y, cardSize.y - src.y));
float inside = smoothstep(0.0, 0.15 * h, edge);
// older smoke was emitted by an older, smaller front
float d = BurnDistance(src, max(progress - age * LAG, 0.0));
float width = mix(400.0, 60.0, age); // smoke spreads while it rises
float puff = exp(-d * d * width) * smoothstep(0.0, 0.2, age) * (1.0 - age) * inside;
density += puff;
warmth += puff * (1.0 - smoothstep(0.0, 0.45, age));
}
// break it into wisps that crawl up
float wisps = smoothstep(0.25, 0.75, SmokeFbm(p * 4.0 + vec2(time * 0.15, time * 0.6)));
float alpha = (1.0 - exp(-density * 1.4)) * wisps * 0.45 * (1.0 - fade);
// young smoke right above the fire is lit by the ember
float warm = clamp(warmth / max(density, 0.001), 0.0, 1.0);
vec3 color = mix(vec3(0.42, 0.42, 0.45), vec3(0.95, 0.55, 0.28), warm * 0.6);
return vec4(color, 1.0) * alpha;
}
""".trimIndent()

This layer is heavier than the burn itself: 8 puffs with 3 octaves of noise each, for every pixel around the note. That’s why everything outside of the smoke area returns right away. If it drops frames on weaker phones, reduce PUFFS first — 5–6 puffs still look nice.

Press enter or click to view image in full size
the same burn, now with smoke :)

Thanks for reading! You can find the fully working example from the beginning of the tutorial on my GitHub. If you find my experiments fun and my explanations helpful, feel free to join my Telegram channel or follow me on Twitter (X). This project is just a hobby, and honestly, my motivation heavily depends on the feedback I get — so I’d be really happy to see you in the channel. And if you feel like it, I’d truly appreciate it if you shared this post on your social media. Happy coding — and happy Halloween! 🎃

Alex Volkov
Alex Volkov

Written by Alex Volkov

Principal Software Engineer with passion to shaders