GPU shader effects for Compose Multiplatform — one API, every platform.
| ShaderLab — Android | ShaderLab — Web |
|---|---|
![]() |
![]() |
| Effect gallery with live parameter sliders | Same codebase, compiled to Wasm/JS |
| ASCIICamera — Real-time Camera Filter | Vertical Carousel — Scroll-driven Effects |
|---|---|
![]() |
![]() |
| Shader on live CameraX frames at 60 fps | Per-card effects driven by scroll state |
Most UI toolkits treat shader effects as an afterthought — platform-specific, hard to parameterize, impossible to compose. ShaderX is designed from the ground up for Compose Multiplatform and solves three real problems:
| Problem | What most teams do | What ShaderX does |
|---|---|---|
| Cross-platform GPU effects | Write AGSL for Android, separate Desktop path, no iOS story | Single Kotlin API — platform backends are an implementation detail |
| Effect reuse & composition | Hard-code effects per screen; cannot combine them | GrayscaleEffect() + VignetteEffect() chains effects with a single + operator |
| Offline image processing | Separate pipeline, no shader reuse | ImageProcessor shares the factory's compiled-shader cache |
| Platform | Shader Backend | Min Version | Composite Effects |
|---|---|---|---|
| Android | AGSL (RuntimeShader) |
API 33 | Full (API 31+) |
| iOS | Skia RuntimeEffect |
iOS 14+ (arm64 / Simulator) | Last effect only |
| Desktop (JVM) | Skia RuntimeEffect |
JDK 21 | Last effect only |
| Web (Wasm/JS) | Skia RuntimeEffect |
Modern browsers | Last effect only |
Full composite chaining across all platforms is on the roadmap.
// settings.gradle.kts
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
// build.gradle.kts (shared module)
dependencies {
implementation("io.github.debanshu777:shaderx:0.1.3")
}Requirements: JDK 21 · Kotlin 2.3+ · Compose Multiplatform 1.10+
One line to apply an effect:
Image(
painter = painterResource("photo.png"),
contentDescription = null,
modifier = Modifier.shaderEffect(GrayscaleEffect())
)Animated effects — handled automatically:
@Composable
fun AnimatedWave() {
// rememberShaderEffect drives the animation loop via withFrameMillis.
// The effect identity is stable across frames — no unnecessary recomposition.
val wave = rememberShaderEffect(WaveEffect(amplitude = 15f, frequency = 6f))
Image(
painter = painterResource("photo.png"),
contentDescription = null,
modifier = Modifier.shaderEffect(wave)
)
}Chain effects with +:
// Grayscale → Vignette → Blur, applied in order
val effect = GrayscaleEffect() + VignetteEffect(radius = 0.6f) + NativeBlurEffect(radius = 3f)
Image(modifier = Modifier.shaderEffect(effect))Offline image processing:
val factory = ShaderFactory.create()
val processor = ImageProcessor.create(factory)
val result = processor.process(imageBytes, SepiaEffect(intensity = 0.8f))
result
.onSuccess { processedBytes -> saveToFile(processedBytes) }
.onFailure { error -> log("Processing failed: ${error.message}") }
factory.close()Nine production-ready effects ship out of the box, each with validated parameter ranges and a stable
@Immutable data class representation.
Converts to grayscale using the standard luminance formula: 0.299R + 0.587G + 0.114B.
GrayscaleEffect(intensity = 1f) // full grayscale
GrayscaleEffect(intensity = 0.5f) // half-blended with original| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
intensity |
Float |
0.0 – 1.0 | 1.0 |
Blend between original and grayscale |
Vintage photograph look using the standard sepia transformation matrix.
SepiaEffect(intensity = 1f)| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
intensity |
Float |
0.0 – 1.0 | 1.0 |
Blend between original and sepia |
Darkens image edges with a smooth center-to-edge gradient — ideal for focus effects and cinematic framing.
VignetteEffect(radius = 0.5f, intensity = 0.5f)| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
radius |
Float |
0.0 – 1.0 | 0.5 |
Distance from center where darkening begins |
intensity |
Float |
0.0 – 1.0 | 0.5 |
Strength of the edge darkening |
Hardware-accelerated Gaussian blur. Uses RenderEffect.createBlurEffect() on Android and Skia's
ImageFilter.makeBlur() on iOS/Desktop — the fastest path on every platform.
NativeBlurEffect(radius = 10f)| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
radius |
Float |
0.0 – 50.0 px | 10.0 |
Blur radius in pixels |
Retro mosaic by snapping fragment coordinates to a pixel grid.
PixelateEffect(pixelSize = 10f)| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
pixelSize |
Float |
1.0 – 100.0 px | 10.0 |
Block size in pixels (auto-clamped to ≥ 1) |
Simulates lens color separation by offsetting the red and blue channels radially from the image center.
ChromaticAberrationEffect(offset = 5f)| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
offset |
Float |
0.0 – 20.0 px | 5.0 |
Channel displacement distance |
Inverts all color channels (1.0 - value), alpha preserved. No configuration needed.
InvertEffect()
// or use the singleton:
InvertEffect.DefaultSinusoidal distortion driven by an internal clock. The time field is excluded from equals/
hashCode so the animation loop's LaunchedEffect never restarts mid-animation when you change
other parameters.
WaveEffect(amplitude = 10f, frequency = 5f, animate = true)| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
amplitude |
Float |
0.0 – 50.0 px | 10.0 |
Max pixel displacement |
frequency |
Float |
1.0 – 20.0 | 5.0 |
Wave cycles across the image |
animate |
Boolean |
— | true |
Toggle animation on/off |
Two-color gradient overlay blended over the original image. Supports both Long ARGB and Compose
Color APIs.
GradientEffect(
color1 = 0xFFF3A397, // coral (bottom-left)
color2 = 0xFFF8EE94, // light yellow (top-right)
intensity = 0.5f
)
// Compose Color convenience API:
effect.withColor1(Color.Blue).withColor2(Color.Magenta)| Parameter | Type | Range | Default | Description |
|---|---|---|---|---|
color1 |
Long (ARGB) |
— | 0xFFF3A397 |
Gradient start color (bottom-left) |
color2 |
Long (ARGB) |
— | 0xFFF8EE94 |
Gradient end color (top-right) |
intensity |
Float |
0.0 – 1.0 | 0.5 |
Blend strength |
All effects are immutable data classes. Updating a parameter returns a new instance — safe for Compose state:
var effect by remember { mutableStateOf(GrayscaleEffect()) }
Slider(
value = effect.intensity,
onValueChange = { effect = effect.copy(intensity = it) }
)
// Or via the generic Float API (useful for generic parameter editors):
effect = effect.withParameter(GrayscaleEffect.PARAM_INTENSITY, 0.75f)
// Or fully typed (works for color and boolean parameters too):
effect = effect.withTypedParameter(
GradientEffect.PARAM_COLOR_1,
ParameterValue.ColorValue(0xFFFF5733)
)Chain any number of effects with the + operator. On Android (API 31+), every effect in the chain
is applied in order. On other platforms, the last effect is applied.
// Operator syntax
val film = SepiaEffect() + VignetteEffect(radius = 0.6f)
// Named factory — identical result
val editorial = CompositeEffect.of(
GrayscaleEffect(),
ChromaticAberrationEffect(offset = 8f),
VignetteEffect(intensity = 0.7f),
)
// Parameters in composites are namespaced with a U+001F delimiter.
// Two effects that both have "intensity" never collide:
val combined = GrayscaleEffect() + SepiaEffect()
val grayscaleIntensity = combined.getParameterValue("0\u001Fintensity")
val sepiaIntensity = combined.getParameterValue("1\u001Fintensity")rememberShaderEffect detects AnimatedShaderEffect implementations and drives a withFrameMillis
loop automatically. You provide the effect definition; the library handles timing.
@Composable
fun LiveWave(amplitude: Float, frequency: Float) {
// Changing amplitude/frequency restarts the effect cleanly.
// The time field is excluded from equals, so mid-animation parameter
// changes don't reset the clock.
val wave = rememberShaderEffect(
WaveEffect(amplitude = amplitude, frequency = frequency)
)
Box(modifier = Modifier.fillMaxSize().shaderEffect(wave))
}By default, Modifier.shaderEffect uses a process-wide LocalShaderFactory singleton. For
fine-grained cache control, provide a scoped factory:
@Composable
fun ShaderGallery() {
// Factory is scoped to this composable's lifecycle.
// maxCacheSize controls GPU memory trade-off.
val factory = rememberShaderFactory(maxCacheSize = 16)
CompositionLocalProvider(LocalShaderFactory provides factory) {
LazyColumn {
items(effects) { effect ->
Image(modifier = Modifier.shaderEffect(effect))
}
}
}
}ImageProcessor shares the factory's compiled-shader cache — processing 1000 images with
GrayscaleEffect compiles the shader once.
val factory = ShaderFactory.create(maxCacheSize = 8)
val processor = ImageProcessor.create(factory)
suspend fun batchProcess(images: List<ByteArray>): List<ByteArray> {
val effect = SepiaEffect(intensity = 0.9f)
return images.mapNotNull { bytes ->
processor.process(bytes, effect).getOrNull()
}
}By default, the effect is applied one frame after layout (the composable must be measured before shader dimensions are known). For known-size contexts, skip the delay entirely:
Image(
modifier = Modifier.shaderEffect(
effect = GrayscaleEffect(),
knownSize = IntSize(imageWidth, imageHeight)
)
)Extend AbstractRuntimeShaderEffect for new AGSL/SkSL effects. The base class handles parameter
dispatch, range validation, and withTypedParameter automatically — you write the shader and
declare parameter handlers.
@Immutable
data class ThermalEffect(
val intensity: Float = 1f,
val heatColor: Long = 0xFFFF4500, // orange-red
) : AbstractRuntimeShaderEffect() {
override val id = "thermal_vision"
override val displayName = "Thermal"
override val shaderSource = """
uniform shader content;
uniform float2 resolution;
uniform float intensity;
layout(color) uniform half4 heatColor;
half4 main(float2 fragCoord) {
half4 color = content.eval(fragCoord);
float luminance = dot(color.rgb, half3(0.299, 0.587, 0.114));
half4 thermal = mix(color, heatColor * luminance, intensity);
return half4(thermal.rgb, color.a);
}
""".trimIndent()
override val parameterHandlers = mapOf(
"intensity" to floatHandler<ThermalEffect>(
spec = PercentageParameter("intensity", "Intensity", defaultValue = 1f),
read = { it.intensity },
write = { e, v -> e.copy(intensity = v) },
),
"heatColor" to colorHandler<ThermalEffect>(
spec = ColorParameter("heatColor", "Heat Color", defaultColor = 0xFFFF4500),
read = { it.heatColor },
write = { e, v -> e.copy(heatColor = v) },
),
)
override fun buildUniforms(width: Float, height: Float) = listOf(
FloatUniform("resolution", width, height),
FloatUniform("intensity", intensity),
ColorUniform("heatColor", heatColor),
)
}Shader contract: Declare uniform shader content; and half4 main(float2 fragCoord). The same
source compiles as AGSL on Android and SkSL on all other platforms.
For platform-native effects (e.g., blur via system APIs), extend AbstractNativeEffect and add a
branch to both AndroidShaderFactory.createNativeEffect and SkiaShaderFactory.createNativeEffect.
All factory and processor operations return ShaderResult<T> — a functional result type with
composable combinators, no exceptions by default:
factory.createRenderEffect(effect, width, height)
.onSuccess { renderEffect ->
graphicsLayer { this.renderEffect = renderEffect }
}
.onFailure { error ->
when (error) {
is ShaderError.CompilationError ->
log("Shader compile failed: ${error.message}")
is ShaderError.UnsupportedEffect ->
log("Not supported on this platform: ${error.effectId}")
is ShaderError.PlatformNotSupported ->
log("Platform limitation: ${error.message}")
is ShaderError.ProcessingError ->
log("Image processing error", error.cause)
is ShaderError.Unknown ->
log("Unexpected error", error.cause)
}
}
// Functional combinators — chain without nesting
val thumbnail = processor.process(bytes, effect)
.map { processedBytes -> decodeThumbnail(processedBytes) }
.recover { _ -> Bitmap.createBitmap(1, 1, ARGB_8888) }
.getOrNull()
// Full combinator set:
// map, flatMap, fold, recover, onSuccess, onFailure, getOrNull, getOrElse, getOrThrowshaderx/
└── src/
├── commonMain/ ← Effect definitions, Compose Modifier, ShaderResult, ImageProcessor
├── androidMain/ ← AGSL backend (RuntimeShader + LRU cache, thread-safe)
└── skiaMain/ ← Shared Skia backend for iOS + Desktop + Web
├── iosMain (depends on skiaMain)
├── jvmMain (depends on skiaMain)
└── wasmJsMain (depends on skiaMain)
Non-Android platforms share one Skia implementation in skiaMain. New effects added there are
immediately available on iOS, Desktop, and Web.
ShaderEffect (sealed interface, @Stable)
├── RuntimeShaderEffect — AGSL/SkSL shader source + buildUniforms()
│ └── AbstractRuntimeShaderEffect — parameterHandlers, range validation
│ ├── GrayscaleEffect
│ ├── SepiaEffect
│ ├── VignetteEffect
│ ├── PixelateEffect
│ ├── ChromaticAberrationEffect
│ ├── InvertEffect
│ ├── WaveEffect (also AnimatedShaderEffect)
│ └── GradientEffect
├── NativeEffect — delegates to platform-native APIs
│ └── NativeBlurEffect (BlurEffect)
├── AnimatedShaderEffect — adds withTime(); driven by rememberShaderEffect
└── CompositeEffect — ordered effect list, + operator, namespaced params
Shader compilation is expensive (GPU driver round-trip). Uniform updates are cheap. ShaderX keeps them separate:
- An access-order LRU cache holds compiled shader programs keyed by effect ID.
- Android (
AndroidShaderFactory): fully synchronized — safe for concurrent access. - Skia (
SkiaShaderFactory): single-threaded per Compose window; SkiaManagedobjects are closed on eviction. - Default cache size: 20 slots. Tune with
ShaderFactory.create(maxCacheSize = N).
Parameters are declared as sealed ParameterSpec subtypes with built-in validation:
| Spec type | Value type | Constraint |
|---|---|---|
PercentageParameter |
Float |
Clamped to 0.0..1.0 |
FloatParameter |
Float |
Clamped to custom range |
PixelParameter |
Float |
Clamped to range, displayed with px |
ToggleParameter |
Boolean |
No numeric range (hasRange = false) |
ColorParameter |
Long (ARGB) |
No range; defaultValue returns 0f (deprecated in favour of getTypedDefaultValue()) |
CompositeEffect namespaces child parameters with a U+001F (ASCII Unit Separator) delimiter —
non-printable and guaranteed absent from normal effect IDs.
ShaderLab — samples/ShaderLab
Full-featured gallery app on Android, iOS, and Desktop. Browse all nine built-in effects, tweak parameters with live sliders and color pickers, and apply composite stacks. Built entirely with the public ShaderX API.
# Android
./gradlew :samples:ShaderLab:androidApp:installDebug
# Desktop
./gradlew :samples:ShaderLab:composeApp:run
# iOS (then open iosApp.xcodeproj in Xcode)
./gradlew :samples:ShaderLab:composeApp:linkDebugFrameworkIosSimulatorArm64ASCIICamera — samples/ASCIICamera
Real-time camera filter on Android. Applies shader effects to live CameraX frames at 60 fps
using ImageProcessor. Demonstrates the offline processing API in a latency-sensitive streaming
context.
./gradlew :samples:ASCIICamera:androidApp:installDebugVerticalCarousel — samples/VerticalCarousel
Scroll-driven effect demo. Each card in a vertical pager applies a different shader, showing how to bind effect parameters to scroll state and animate transitions between items.
| Feature | Status |
|---|---|
| Full composite chaining on iOS / Desktop / Web | Planned |
| More built-in effects (noise, halftone, bloom, edge detection) | Planned |
| Shader hot-reload in debug builds | Planned |
| Declarative parameter animation DSL | Planned |
| WebGPU backend (Wasm) | Planned |
Video frame processing via VideoProcessor |
Planned |
# Build library (all platforms)
./gradlew :shaderx:build
# Run all checks and tests
./gradlew :shaderx:check
# JVM unit tests only
./gradlew :shaderx:jvmTest
# Publish to local Maven for integration testing
./gradlew :shaderx:publishToMavenLocal| Category | Technology | Version |
|---|---|---|
| Language | Kotlin | 2.3.21 |
| UI Framework | Compose Multiplatform | 1.10.3 |
| Build System | Gradle / AGP | 9.1.1 |
| Android compile SDK | — | 37 |
| Android min SDK | — | 33 |
| Coroutines | kotlinx-coroutines | 1.10.2 |
| Serialization | kotlinx-serialization | 1.11.0 |



