Adding global illumination on Compose Screen
Hi everyone!
I often say that a lot of visual wow factor comes from small details. A complex and technically impressive effect can still feel heavy or unattractive. At the same time, just a few highlights or a subtle blur can completely change the look and feel of an interface. Suddenly, even very simple elements start to look polished and expensive. Have you ever noticed that?
Have you ever noticed how beautiful UI renders on Pinterest or Dribbble can look, while at the same time it’s hard to explain exactly why they look so good?
If you look closely, the individual elements are often quite simple. Sometimes they are not even particularly impressive on their own. Yet together, the whole screen feels polished, glossy, and somehow more cohesive. I think one of the reasons is lighting.
When a designer (or an AI) creates a screen mockup, they usually think about the screen as a whole. It is a single canvas. A gradient, glow, or highlight applied to one element often affects the surrounding elements as well.
When we start implementing that design in code, we usually lose this “glue”. The styling of a component lives inside that component. A glow belongs to a button. A gradient belongs to a card. They don’t influence nearby elements. That’s completely normal. That’s how UI programming works.
But what if we tried to bring some of that cohesion back?
What if we used shaders in Compose to make the glow from one element subtly affect the rest of the screen? Would we get closer to that polished look we often see in mockups? As it turns out, the answer is yes.
Take a look at the 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 in shaders, and have no clue what is going on here, feel free to read my book for very beginners.
Usually, in my tutorials, I try to take things all the way to the final polished result. However, the whole point of this effect is how a light source influences other elements on the screen, so for simplicity we’ll work with just two elements: one that emits light and another that receives it.
I’ll walk through the entire architecture behind this approach, so adding more elements later will be straightforward.
By the end of this tutorial, we’ll have something like this:
As I mentioned above, the idea is to have two different types of elements: light emitters and light receivers.
From the Compose side, I built it like this: there is a special LightingScope, and everything placed inside it gets access to modifier extensions. This allows any element inside the scope to be marked either as a light source or as a receiver:
LightingScope() {
Box(modifier = Modifier
.lightSource(
color = Color(0xFF79E4F3),
intensity = 1f,
bloomIntensity = 1f,
)
)
Box(
modifier = Modifier.receivesLight(),
)
}For now, let’s focus only on the light emitter, meaning the lightSource method. But first, let’s create our custom scope.
interface LightingScopeComposition {
fun Modifier.lightSource(
color: Color,
intensity: Float = 1f,
bloomIntensity: Float = 1f,
): Modifier
}
private class LightingScopeCompositionImpl(
private val state: LightingState,
) : LightingScopeComposition {
override fun Modifier.lightSource(
color: Color,
intensity: Float,
bloomIntensity: Float,
): Modifier = composed {
val key = remember { Any() }
var center by remember { mutableStateOf<Offset?>(null) }
DisposableEffect(key) {
onDispose { state.unregister(key) }
}
val resolvedCenter = center
if (resolvedCenter != null) {
SideEffect {
if (intensity > 0f || bloomIntensity > 0f) {
state.register(key, LightSource(resolvedCenter, color, intensity, bloomIntensity))
} else {
state.unregister(key)
}
}
}
onGloballyPositioned { coords ->
center = coords.boundsInRoot().center
}
}
data class LightSource(
val center: Offset,
val color: Color,
val intensity: Float, // how strongly this light illuminates other elements
val bloomIntensity: Float, // how strongly this light draws its own visible halo
)
@Stable
class LightingState {
private val _lights: SnapshotStateMap<Any, LightSource> =
emptyList<Pair<Any, LightSource>>().toMutableStateMap()
val lights: Collection<LightSource> get() = _lights.values
/** Size of the lighting scope, used to normalize light falloff across the scene. */
var sceneSize: Size by mutableStateOf(Size.Zero)
fun register(id: Any, source: LightSource) {
_lights[id] = source
}
fun unregister(id: Any) {
_lights.remove(id)
}
}
This is mostly the boilerplate required to define a custom Compose scope and keep track of all active light sources. The LightingState acts as a central registry, while the lightSource modifier automatically registers and updates a light whenever its position changes on the screen.
At this stage, the implementation doesn’t render any lighting yet. Its only responsibility is to collect information about every light source such as its position, color, and intensity, so it can be used later when we start calculating the actual lighting effects.
Now let’s create an actual LightningScope method
@Composable
fun LightingScope(
modifier: Modifier = Modifier,
content: @Composable LightingScopeComposition.() -> Unit,
) {
val state = remember { LightingState() }
val receiver = remember(state) { LightingScopeCompositionImpl(state) }
val shader = remember {
// Later we will add the shader here!
}
var scopeOrigin by remember { mutableStateOf(Offset.Zero) }
Box(
modifier = modifier
.onGloballyPositioned { coords ->
scopeOrigin = coords.positionInRoot()
}
.onSizeChanged { size ->
state.sceneSize = size.toSize()
shader.setFloatUniform(
"resolution",
size.width.toFloat(),
size.height.toFloat(),
)
}
.graphicsLayer {
val lights = state.lights
shader.setFloatUniform("scopeOrigin", scopeOrigin.x, scopeOrigin.y)
shader.setLightGeometry(lights)
shader.setBloomIntensities(lights)
renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "image")
.asComposeRenderEffect()
}
) {
receiver.content()
}
}Now take a closer look at the two extension methods I added here:
setLightGeometrysetBloomIntensities
Let’s spend a moment talking about what they do.
fun RuntimeShader.setLightGeometry(lights: Collection<LightSource>) {
val positions = FloatArray(MAX_LIGHTS * 2)
val colors = FloatArray(MAX_LIGHTS * 3)
lights.take(MAX_LIGHTS).forEachIndexed { i, light ->
positions[i * 2] = light.center.x
positions[i * 2 + 1] = light.center.y
colors[i * 3] = light.color.red
colors[i * 3 + 1] = light.color.green
colors[i * 3 + 2] = light.color.blue
}
setFloatUniform("lightPositions", positions)
setFloatUniform("lightColors", colors)
}fun RuntimeShader.setBloomIntensities(lights: Collection<LightSource>) {
val intensities = FloatArray(MAX_LIGHTS)
lights.take(MAX_LIGHTS).forEachIndexed { i, light -> intensities[i] = light.bloomIntensity }
setFloatUniform("bloomIntensities", intensities)
}In general, both of these methods are responsible for passing the required coordinates, colors, and intensity values to the shader. They look a bit more complicated than they actually are because of a limitation in AGSL: shaders cannot work with dynamically sized arrays.
Instead, array sizes must be defined in advance. Because of that, I introduced a MAX_LIGHTSconstant and set it to 8. This means that at most eight light sources can be active on the screen at the same time.
In practice, these methods do nothing particularly complex; they simply populate the shader arrays with the data for the currently active light sources.
Now it’s time to write the shader itself!
Again, it would look much simpler if we only had one light source. But since we want to support multiple lights, and the shader needs to know how many of them exist at runtime, we have to use a small workaround: build the shader string dynamically.
For that, I created a separate method called buildGlobalBloomShader. Inside this method, the number of lights is injected into the shader as part of a parameterized string.
There is also a small condition: if some value in the array is less than zero, it means that this slot was not filled, so we simply skip it. This is needed because the array still has to have a fixed size in advance.
Get Alex Volkov’s stories in your inbox
Join Medium for free to get updates from this writer.
After that, the shader uses a pretty standard falloff calculation, as I mentioned before. If you’ve read my previous tutorials, most of this should look familiar. We simply multiply the light color by a falloff function, which fades exponentially as the distance from the center increases.
internal fun buildGlobalBloomShader(maxLights: Int): String = """
uniform vec2 scopeOrigin;
uniform vec2 lightPositions[$maxLights];
uniform vec3 lightColors[$maxLights];
uniform float bloomIntensities[$maxLights];
vec4 main(float2 fragCoord) {
vec4 src = image.eval(fragCoord);
vec2 worldPos = scopeOrigin + fragCoord;
float sceneScale = max(length(resolution), 1.0);
vec3 bloom = vec3(0.0);
for (int i = 0; i < $maxLights; i++) {
if (bloomIntensities[i] <= 0.0) continue; // inactive slot
float nd = length(worldPos - lightPositions[i]) / sceneScale;
float falloff = exp(-5.0 * nd);
bloom += lightColors[i] * falloff * bloomIntensities[i];
}
vec3 color = src.rgb + bloom;
return vec4(clamp(color, 0.0, 1.0), src.a);
}
""".trimIndent()That’s all we need for the light emitter. Next, let’s create a simple layout to visualize the result and make sure everything is working as expected.
@Composable
fun IlluminateUiScreen() {
var lightEnabled by remember { mutableStateOf(true) }
val intensity = animateFloatAsState(
if (lightEnabled) 1f else 0f
)
Column (
modifier = Modifier
.fillMaxSize()
.background(Palette.Background)
.safeContentPadding(),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
LightingScope() { // <-- LigthinScope, each node inside it can emmit light now!
Row(
modifier = Modifier.fillMaxWidth(fraction = 0.7f),
horizontalArrangement = Arrangement.SpaceBetween
) {
Box(
modifier = Modifier
.size(60.dp)
.lightSource(
color = Color(0xFF79E4F3),
intensity = intensity.value,
bloomIntensity = intensity.value
) // <-- Here is our light emmiter!
.background(
color = Color(0xFF0090A1),
shape = CircleShape
),
)
Box(
modifier = Modifier
.size(60.dp)
.background(
color = Color(0xFF0090A1),
shape = CircleShape
),
)
}
}
Spacer(modifier = Modifier.height(56.dp))
Button(
onClick = { lightEnabled = !lightEnabled },
) {
Text(text = if (!lightEnabled) "Switch on!" else "Switch off!")
}
}
}We now can already controll color and bloom intensity of that view. But we still are missing light reciever.
Let’s start by adding that method to our interface:
interface LightingScopeComposition {
fun Modifier.lightSource(
color: Color,
intensity: Float = 1f,
bloomIntensity: Float = 1f,
): Modifier
//Add light reciever
fun Modifier.receivesLight(strength: Float = 1f): Modifier
}This is a good moment to explain the difference between the intensity parameter in a LightSource and the strength parameter in a light receiver.
The reason these are separate is that I intentionally wanted to decouple the visual brightness of a light source from its actual lighting influence. That’s why I introduced a separate parameter called bloomIntensity for the visible glow, while intensity represents the physical strength of the light itself.
In practice, you may not always want a light source to appear very bright on the screen, while still having a strong effect on nearby UI elements. The intensity parameter controls exactly that. It defines how strongly the light propagates through the receiver shader, regardless of how bright the source itself appears.
The receiver’s strength parameter serves a similar purpose from the opposite side. It controls how strongly a particular element reacts to incoming light. This means that two elements placed next to each other can respond differently to the same light source: one may reflect intensely, while the other only reacts subtly.
You’ll see this directly in the shader implementation. The final lighting contribution is calculated by multiplying these two values together, allowing both the light source and the receiving element to influence the final result.
override fun Modifier.receivesLight(strength: Float): Modifier = composed {
val shader = remember {
Shader(buildElementReceiverShader(MAX_LIGHTS)).getRuntimeShader()
}
val time = provideTimeAsState()
var origin by remember { mutableStateOf(Offset.Zero) }
this
.onGloballyPositioned { coords ->
origin = coords.boundsInRoot().topLeft
}
.onSizeChanged { size ->
shader.setFloatUniform(
"resolution",
size.width.toFloat(),
size.height.toFloat(),
)
}
.graphicsLayer {
val lights = state.lights
val scene = state.sceneSize
val sceneScale = sqrt(scene.width * scene.width + scene.height * scene.height)
shader.setFloatUniform("time", time.value)
shader.setFloatUniform("elementOrigin", origin.x, origin.y)
shader.setFloatUniform("strength", strength)
shader.setFloatUniform("sceneScale", sceneScale.coerceAtLeast(1f))
shader.setLightGeometry(lights)
shader.setLightIntensities(lights)
renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "image")
.asComposeRenderEffect()
}
}Here, we basically do the same thing as we did for bloom: we calculate the coordinates and pass all the required data into the shader.
In the same way, we build the receiver shader dynamically with buildElementReceiverShader, because we still have a dynamic list of lights that has to be represented as a fixed-size array inside AGSL.
However, there is one new extension method here: setLightIntensities.
fun RuntimeShader.setLightIntensities(lights: Collection<LightSource>) {
val intensities = FloatArray(MAX_LIGHTS)
lights.take(MAX_LIGHTS).forEachIndexed { i, light -> intensities[i] = light.intensity }
setFloatUniform("lightIntensities", intensities)
}Since we are working with arrays and constructing the shader string from them, let’s first put the whole shader here for convenience. After that, I’ll go through the most interesting parts step by step.
The last thing left to do is add the receiver shader itself.
internal fun buildElementReceiverShader(maxLights: Int): String = """
uniform vec2 elementOrigin;
uniform float strength;
uniform float sceneScale; // scene diagonal in px — normalizes distance to 0..~1
uniform vec2 lightPositions[$maxLights];
uniform vec3 lightColors[$maxLights];
uniform float lightIntensities[$maxLights];
vec4 main(float2 fragCoord) {
vec4 src = image.eval(fragCoord);
vec2 worldPos = elementOrigin + fragCoord;
vec2 outwardNormal = normalize(fragCoord - resolution * 0.5 + vec2(0.0001, 0.0));
vec3 accumulated = vec3(0.0); // soft surface tint (raw light color)
vec3 glare = vec3(0.0); // vivid directional edge highlight
for (int i = 0; i < $maxLights; i++) {
if (lightIntensities[i] <= 0.0) continue; // inactive slot
float nd = length(worldPos - lightPositions[i]) / sceneScale;
float falloff = exp(-5.0 * nd);
accumulated += lightColors[i] * falloff * lightIntensities[i];
vec3 hsv = RGBtoHSV(lightColors[i]);
hsv.z = 1.0;
vec2 toLight = normalize(lightPositions[i] - worldPos);
float facing = max(0.0, dot(outwardNormal, toLight));
glare += HSVtoRGB(hsv) * falloff * lightIntensities[i] * facing;
}
float edgeMask = 0.0;
float weightSum = 0.0;
for (int r = 1; r < 10; r += 2) {
float rf = float(r);
float minA = min(
min(image.eval(fragCoord - vec2(rf, 0.0)).a, image.eval(fragCoord + vec2(rf, 0.0)).a),
min(image.eval(fragCoord - vec2(0.0, rf)).a, image.eval(fragCoord + vec2(0.0, rf)).a));
float w = 1.0 - rf / 10.0;
edgeMask += src.a * (1.0 - minA) * w;
weightSum += w;
}
edgeMask /= weightSum;
vec2 localUv = fragCoord / resolution;
float topRim = 1.0 - smoothstep(0.0, 0.55, localUv.y);
float bottomRim = smoothstep(0.45, 1.0, localUv.y);
float edgeBoost = 0.72 + topRim * 0.52 + bottomRim * 0.52;
vec3 color = src.rgb + accumulated * strength * edgeBoost * src.a;
color += glare * edgeMask * 2.5;
return vec4(clamp(color, 0.0, 1.0), src.a);
}
""".trimIndent()Let’s explain the main idea of that shader:
vec2 outwardNormal = normalize(fragCoord - resolution * 0.5 + vec2(0.0001, 0.0));We have a flat element, no real 3D geometry, but we still want it to react to light direction. So we cheat: we treat the vector from the center of the element outwards as if it were a surface normal. Pixels on the left point left, pixels on the top point up, and so on — like a very soft dome. That tiny 0.0001 is just there so we never call normalize on a zero vector at the exact center (which would give garbage). It costs nothing and it's the thing that lets us do directional lighting later.
float nd = length(worldPos - lightPositions[i]) / sceneScale;
float falloff = exp(-5.0 * nd);For each light we measure how far the pixel is from it, then normalize that distance by the scene diagonal (sceneScale) so the number lands in a comfortable 0..1 range no matter the screen size. Then we run it through an exponential. The result is bright right next to the light and fades away smoothly. Exponential falloff is great here because it never snaps to a hard zero — the light always blends instead of cutting off. It is exactly what we did in bloom shader.
Next, we build the light out of two independent pieces, and keeping them separate is what gives the effect its depth.
The first is a soft surface tint — just the raw light color fading with distance. This is the gentle wash of color bleeding onto the element:
accumulated += lightColors[i] * falloff * lightIntensities[i];Remember, that we added light intensy, not a bloom intensy, here we are using it!
The second is a vivid edge glare, and it leans on two tricks.
The first is HSV. The whole reason to convert here is that in HSV brightness lives in its own channel, separate from the color. We push hsv.z = 1.0, which forces the glare to the pure, full-brightness version of the light's color no matter how dark or muddy the original color was. So even a dim light throws a crisp, saturated highlight. Doing this in RGB would mean rescaling all three channels by hand and guessing.
The second is dot(outwardNormal, toLight). We take the fake normal and the direction to the light, and the dot product tells us how directly that pixel faces the light. The max(0.0, ...) clamps the back side to zero, so pixels facing away get no glare at all. This is what makes the highlight slide along the side of the element that points toward the light, instead of glowing everywhere equally.
vec3 hsv = RGBtoHSV(lightColors[i]);
hsv.z = 1.0;
...
float facing = max(0.0, dot(outwardNormal, toLight));
glare += HSVtoRGB(hsv) * falloff * lightIntensities[i] * facing;Now we already have lightining on the reciever but without glare on the rim on the edge it looks to fake.
Let’s add the glare to sit on the rim of the element, like a real rim light, not smeared across the whole face. The nice part is that we don’t need to know the element’s shape — we read it straight out of the alpha channel. For each pixel we look at its neighbors at growing radii (1, 3, 5, 7, 9 px) and take the minimum neighbor alpha. If the pixel itself is opaque (src.a) but something nearby is transparent (1.0 - minA is large), then we're sitting right next to the silhouette — that's an edge. Sampling at several radii and weighting the closer ones more heavily (w = 1.0 - rf / 10.0) turns that into a soft band, strongest at the border and fading inward, instead of a hard one-pixel line. Dividing by weightSum at the end normalizes it back to 0..1.
for (int r = 1; r < 10; r += 2) {
float minA = min(min(...left, ...right), min(...up, ...down));
float w = 1.0 - rf / 10.0;
edgeMask += src.a * (1.0 - minA) * w;
...
}
edgeMask /= weightSum;Next we fake a little 3D shaping with smoothstep. There's a base level of 0.72 everywhere, plus an extra boost near the top and near the bottom. So the top and bottom edges catch noticeably more light than the middle, which the eye reads as a rounded, beveled surface rather than a flat sticker.
float topRim = 1.0 - smoothstep(0.0, 0.55, localUv.y);
float bottomRim = smoothstep(0.45, 1.0, localUv.y);
float edgeBoost = 0.72 + topRim * 0.52 + bottomRim * 0.52;Now we stack the layers onto the original pixel. The soft tint goes on first, scaled by the global strength, shaped by edgeBoost, and multiplied by src.a so it only lands on the element and not on the transparent area around it. Then the vivid glare goes on top, multiplied by edgeMask so it hugs the rim, and pushed with * 2.5 so it actually pops. Finally we clamp to keep everything in range and return the original alpha so the element keeps its shape.
vec3 color = src.rgb + accumulated * strength * edgeBoost * src.a;
color += glare * edgeMask * 2.5;
return vec4(clamp(color, 0.0, 1.0), src.a);The mental model is simple: world-space distance gives you reach, the fake normal plus the dot product gives you direction, the alpha edge mask tells you where the rim is, and the HSV push keeps the highlight vivid — and the two light layers, the soft wash and the sharp glare, are just the same building block used twice.
I’ve change color for light source, and placed button inside our LightingScope and immediatelly get that results:
Thanks for reading! You can find the fully workable example from the tutorial beginning animation on my github. If these experiments and tutorials are useful to you, join my Telegram channel and follow me on Twitter (X) — that’s where I share new shaders and breakdowns first. Feedback improves what I build next, so drop by and tell me what you want to see. And if this post helped you, share it — it’s the best way to support the work. Happy coding!









