DEV Community

Cover image for Anime-style action FX in Compose Multiplatform, no shaders, one codebase
Özge Güner for SolidKey AB

Posted on

Anime-style action FX in Compose Multiplatform, no shaders, one codebase

You know the look. A sword swing trails a glowing ribbon of water. A bolt of lightning forks across the frame. Speed lines snap inward on the impact beat, and a shower of embers drifts up behind it. That is anime action, and it usually implies a game engine, platform shaders, or a pile of dependencies.

This post builds that look in plain Compose Multiplatform. The exact same Kotlin runs on Android and iOS, there are no shaders, and nothing extra is pulled into your app. Every snippet is from KMPMedia, an open-source library on Maven Central:

implementation("se.solidkey:kmpmedia-lib:1.32.0")
Enter fullscreen mode Exit fullscreen mode

The constraint that shapes everything

The usual way to get rich motion FX is a fragment or pixel shader (RuntimeShader on Android 33+, SkSL on iOS). Shaders are powerful, but they cost you two things that matter for a multiplatform app. They split the codebase, because now you maintain shader code with its own language and its own capability floor per platform. And they are not deterministic across GPUs, so the "same" effect can look subtly different from device to device.

There is another path. Keep the math on the CPU, in shared commonMain, and let Compose's Canvas and graphicsLayer do the GPU compositing. Two rules make that fast enough:

  1. Parse once, interpolate per frame. Build the heavy geometry a single time, then only lerp points each frame.
  2. Glow by addition, not blur. A soft halo is a few additive passes of the same shape, which the GPU composites for free.

Follow those two rules and you get 60fps, byte-for-byte determinism, and one codebase. Everything below does exactly that.

A slash ribbon

The hero effect is a tapered ribbon drawn along a spline, with a draw-on reveal and a flowing gradient. The centreline is a centripetal Catmull-Rom spline (so it never loops or cusps on sharp input), sampled once; each frame only rebuilds the outline and advances the reveal. The gradient is interpolated in OKLab rather than sRGB, so the middle of a blue-to-navy ramp stays saturated instead of going grey. The edge wobble comes from simplex noise plus fBm.

Three presets are one primitive wearing three elements:

import androidx.compose.ui.Modifier
import androidx.compose.foundation.layout.fillMaxSize
import com.solidkey.painpoints.fx.OGSlashView
import com.solidkey.painpoints.fx.OGSlashes

// Water: a rippling blue sweep.
OGSlashView(spec = OGSlashes.WATER, modifier = Modifier.fillMaxSize())

// Flame and Thunder are the same call, different data.
OGSlashView(spec = OGSlashes.FLAME)
OGSlashView(spec = OGSlashes.THUNDER)
Enter fullscreen mode Exit fullscreen mode

A slash is just data, so you can tweak a preset or author your own. Copy and adjust the fields you care about:

val myWater = OGSlashes.WATER.copy(
    width = 0.14f,
    colorStart = "#9BE7FF",
    colorEnd = "#1E6FFF",
    edge = "wave",      // "smooth" | "wave" | "rough" | "bolt"
    glow = 0.85f,
    revealMs = 220f,    // the draw-on time
)
OGSlashView(spec = myWater)
Enter fullscreen mode Exit fullscreen mode

Because the spec is a plain serializable type, it also round-trips through JSON. OGSlashes.slashPrompt("a slow turquoise water arc") hands a language model the exact contract, and OGSlashes.decodeSpec(reply) turns the reply back into a spec. A designer or an assistant can ship a .slash pack with no code and no rebuild.

Forked lightning

Lightning is a recursive midpoint-displacement channel with branches, flickered over a short strike window. The jaggedness and the branch anchors are seeded, so the bolt is identical on both platforms.

import com.solidkey.painpoints.fx.OGLightningView
import com.solidkey.painpoints.fx.OGLightnings

OGLightningView(spec = OGLightnings.BOLT, modifier = Modifier.fillMaxSize())
// Also: OGLightnings.STORM (multi-strike) and OGLightnings.SPARK (small, fast).
Enter fullscreen mode Exit fullscreen mode

Speed lines

The manga impact frame: radial lines converging on a point, pulsing. Dirt cheap, and it sells motion more than almost anything else.

import com.solidkey.painpoints.fx.OGSpeedLinesView
import com.solidkey.painpoints.fx.OGSpeedLines

OGSpeedLinesView(spec = OGSpeedLines.IMPACT)
// Also: OGSpeedLines.FOCUS (converge on a subject) and OGSpeedLines.MOTION (directional).
Enter fullscreen mode Exit fullscreen mode

Elemental particles

The particle system is a struct-of-arrays simulation: positions, sizes and colours live in flat FloatArray and IntArray buffers, so there is no per-frame allocation and a hard cap on live particles. It is deterministic from a seed, which is what lets the compositor export an identical GIF later.

import com.solidkey.painpoints.particle.OGParticleView
import com.solidkey.painpoints.particle.OGParticles

OGParticleView(spec = OGParticles.EMBERS, modifier = Modifier.fillMaxSize())
// Elemental presets: EMBERS, PETALS, DROPLETS, LEAVES.
// Classic presets: CONFETTI, SPARKS, SNOW, BOKEH, RAIN, FIREWORKS.
Enter fullscreen mode Exit fullscreen mode

Glow and bloom

There are two ways to make something luminous, and they make a deliberate trade.

Modifier.ogGlow is the portable one: it draws a few additive copies of the content, wide and faint through tight and bright, for a halo that hugs the silhouette. No blur, no API floor, pixel-identical on both platforms.

import androidx.compose.ui.graphics.Color
import com.solidkey.painpoints.fx.ogGlow

Box(Modifier.ogGlow(color = Color(0xFFFFE08A), intensity = 0.7f)) {
    Blade()
}
Enter fullscreen mode Exit fullscreen mode

Modifier.ogBloom is the cinematic one: it redraws the content through a real platform gaussian blur and blends it additively. It looks softer and richer, but it is opt-in, because the blur needs Android 31+ (it no-ops to a crisp draw below that) and is not pixel-identical across platforms.

import androidx.compose.ui.unit.dp
import com.solidkey.painpoints.fx.ogBloom

Box(Modifier.ogBloom(radius = 16.dp, intensity = 0.8f)) {
    Blade()
}
Enter fullscreen mode Exit fullscreen mode

Rule of thumb: reach for ogGlow when you need the result to be identical everywhere (including an export), and ogBloom when you just want it to look its best on a modern device.

Composing the beat

Each effect is its own composable, so a full action beat is a stack. Here is a water-surface slash: speed lines framing the hit, the ribbon itself, and droplets trailing off it.

Box(Modifier.fillMaxSize()) {
    OGSpeedLinesView(OGSpeedLines.IMPACT)
    OGSlashView(OGSlashes.WATER, Modifier.fillMaxSize())
    OGParticleView(OGParticles.DROPLETS, Modifier.fillMaxSize())
}
Enter fullscreen mode Exit fullscreen mode

Drop the same layers onto the library's OGComposition timeline instead of a Box, keyframe their position and opacity, and you can export the whole thing to an animated GIF or a real H.264 MP4 on the device, with OGComposition.exportGif() and exportMp4(). The compositing math lives in tested commonMain, so the GIF comes out byte-identical on both platforms.

Why it stays at 60fps

  • The slash centreline, the lightning channel and the particle buffers are built once in remember and only interpolated per frame. No re-parse, no per-frame allocation.
  • Glow is additive blend, not a blur, so it is effectively free on the GPU.
  • The transforms ride a single graphicsLayer, so moving an effect does not trigger recomposition.
  • Everything is seeded and deterministic, which is both why it looks identical on Android and iOS and why the export can be byte-for-byte reproducible.

Grounded in research, not vibes

None of this is ad-hoc. Each technique maps to a named result: OKLab (Ottosson, 2020) for the perceptual gradient, simplex noise plus fBm (Perlin, 2002; Gustavson; Mandelbrot) for the organic edges, recursive midpoint displacement (Fournier, Fussell, Carpenter, 1982) for the lightning, and median-cut plus Floyd-Steinberg plus LZW for the GIF encoder. The full map from feature to API to citation is in ACADEMIC_FOUNDATIONS.md.

Try it

implementation("se.solidkey:kmpmedia-lib:1.32.0")
Enter fullscreen mode Exit fullscreen mode

The library is MIT, on Maven Central, and runs on Android and iOS from one codebase. There is a Compose Multiplatform demo app that exercises every effect above, with live sliders and a JSON editor, and prebuilt APKs on its releases page.

If you build something with it, I would love to see it.

Top comments (0)