Why ShaderX?
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:
Platform Support
Full composite chaining across all platforms is on the roadmap.
Installation
dependencyResolutionManagement {
repositories {
mavenCentral()
}
}
dependencies {
implementation("io.github.debanshu777:shaderx:0.1.3")
}
Requirements: JDK 21 · Kotlin 2.3+ · Compose Multiplatform 1.10+
Quick Start
One line to apply an effect:
Image(
painter = painterResource("photo.png"),
contentDescription = null,
modifier = Modifier.shaderEffect(GrayscaleEffect())
)
Animated effects — handled automatically:
@Composable
fun AnimatedWave() {
val wave = rememberShaderEffect(WaveEffect(amplitude = 15f, frequency = 6f))
Image(
painter = painterResource("photo.png"),
contentDescription = null,
modifier = Modifier.shaderEffect(wave)
)
}
Chain effects with +:
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()
Built-in Effects
Nine production-ready effects ship out of the box, each with validated parameter ranges and a stable
@Immutable data class representation.
GrayscaleEffect
Converts to grayscale using the standard luminance formula: 0.299R + 0.587G + 0.114B.
GrayscaleEffect(intensity = 1f)
GrayscaleEffect(intensity = 0.5f)
| Parameter | Type | Range | Default | Description |
|---|
intensity |
SepiaEffect
Vintage photograph look using the standard sepia transformation matrix.
SepiaEffect(intensity = 1f)
| Parameter | Type | Range | Default | Description |
|---|
intensity |
VignetteEffect
Darkens image edges with a smooth center-to-edge gradient — ideal for focus effects and cinematic
framing.
VignetteEffect(radius = 0.5f, intensity = 0.5f)
NativeBlurEffect
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 |
PixelateEffect
Retro mosaic by snapping fragment coordinates to a pixel grid.
PixelateEffect(pixelSize = 10f)
| Parameter | Type | Range | Default | Description |
|---|
pixelSize |
ChromaticAberrationEffect
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 |
InvertEffect
Inverts all color channels (1.0 - value), alpha preserved. No configuration needed.
InvertEffect()
InvertEffect.Default
WaveEffect (Animated)
Sinusoidal 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)
GradientEffect
Two-color gradient overlay blended over the original image. Supports both Long ARGB and Compose
Color APIs.
GradientEffect(
color1 = 0xFFF3A397,
color2 = 0xFFF8EE94,
intensity = 0.5f
)
effect.withColor1(Color.Blue).withColor2(Color.Magenta)
Advanced Usage
Dynamic Parameter Updates
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) }
)
effect = effect.withParameter(GrayscaleEffect.PARAM_INTENSITY, 0.75f)
effect = effect.withTypedParameter(
GradientEffect.PARAM_COLOR_1,
ParameterValue.ColorValue(0xFFFF5733)
)
Effect Composition
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.
val film = SepiaEffect() + VignetteEffect(radius = 0.6f)
val editorial = CompositeEffect.of(
GrayscaleEffect(),
ChromaticAberrationEffect(offset = 8f),
VignetteEffect(intensity = 0.7f),
)
val combined = GrayscaleEffect() + SepiaEffect()
val grayscaleIntensity = combined.getParameterValue()
sepiaIntensity = combined.getParameterValue()
Animation
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) {
val wave = rememberShaderEffect(
WaveEffect(amplitude = amplitude, frequency = frequency)
)
Box(modifier = Modifier.fillMaxSize().shaderEffect(wave))
}
Scoped Shader Factory
By default, Modifier.shaderEffect uses a process-wide LocalShaderFactory singleton. For
fine-grained cache control, provide a scoped factory:
@Composable
fun ShaderGallery() {
val factory = rememberShaderFactory(maxCacheSize = 16)
CompositionLocalProvider(LocalShaderFactory provides factory) {
LazyColumn {
items(effects) { effect ->
Image(modifier = Modifier.shaderEffect(effect))
}
}
}
}
Offline Image Processing
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()
}
}
First-Frame Optimization
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)
)
)
Custom Effects
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.
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.
Error Handling
All factory and processor operations return ShaderResult<T> — a functional result type with
composable combinators, no exceptions by default:
Architecture
Source-Set Layout
shaderx/
└── 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.
Type Hierarchy
Caching Strategy
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; Skia objects are
closed on eviction.
ParameterSpec System
Parameters are declared as sealed ParameterSpec subtypes with built-in validation:
CompositeEffect namespaces child parameters with a U+001F (ASCII Unit Separator) delimiter —
non-printable and guaranteed absent from normal effect IDs.
Sample Applications
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.
./gradlew :samples:ShaderLab:androidApp:installDebug
./gradlew :samples:ShaderLab:composeApp:run
./gradlew :samples:ShaderLab:composeApp:linkDebugFrameworkIosSimulatorArm64
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:installDebug
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.
Roadmap
Build Commands
./gradlew :shaderx:build
./gradlew :shaderx:check
./gradlew :shaderx:jvmTest
./gradlew :shaderx:publishToMavenLocal
Technology Stack