Kotlin DSL for AGSL Shaders in Android: A Typed Approach
Developing AGSL shaders for Android with RuntimeShader and RenderEffect often hits roadblocks like string literals, manual uniform binding, and poor IDE support. Kotlin DSL fixes these issues by generating native AGSL code without performance hits. The library turns typed constructs into ready-to-use shaders while keeping access to raw AGSL via agslSource().
This lets you focus on effect logic instead of string syntax. Supports API 33+, integrates with Compose, and includes a standard library of procedural primitives.
Pain Points of Raw AGSL in Kotlin
Shaders as strings lose IDE perks like autocomplete, refactoring, and type checking. Uniforms are declared manually—name mismatches crash your app. Complex effects with smoothstep, fract, and mix balloon into unreadable messes. In Compose, you add extra boilerplate for time and updates.
DSL removes these hurdles: uniforms are typed, intermediate calculations get named via let(), and standard functions are called declaratively.
Adding the Library
Add these dependencies to your build.gradle:
dependencies {
implementation("io.github.i-redbyte:redbytefx-core:1.0.0")
implementation("io.github.i-redbyte:redbytefx-compose:1.0.0")
implementation("io.github.i-redbyte:redbytefx-stdlib:1.0.0")
}
Comparison: Wave Displacement
Raw AGSL
uniform shader content;
uniform float wave_amplitude;
uniform float wave_frequency;
half4 main(float2 fragCoord) {
float2 offset = float2(
0.0,
sin(fragCoord.x * wave_frequency) * wave_amplitude
);
return content.eval(fragCoord + offset);
}
Kotlin DSL
val effect = redbytefx {
val amplitudeUniform = uniformFloat(0f, "wave_amplitude")
val frequencyUniform = uniformFloat(0.08f, "wave_frequency")
val x = let(fragCoord.x, "x")
val waveOffset = let(
float2(0f, sin(x * frequencyUniform) * amplitudeUniform),
"wave_offset"
)
sample(fragCoord + waveOffset)
}
The generated AGSL matches the logic exactly, but your source code is typed and readable.
Effect Examples by Complexity
1. Wave: Basic Effect
Simple Y-offset with sin. Perfect for learning fragCoord, sample(), uniforms, and let() to name steps without extra complexity.
2. Signal: Functions and Masks
val effect = redbytefx {
val densityUniform = uniformFloat(8f, "signal_density")
val lineWidthUniform = uniformFloat(0.08f, "signal_line_width")
val amountUniform = uniformFloat(0.85f, "signal_amount")
val pulseBand = fn(
name = "pulse_band",
arg1 = FloatType,
arg2 = FloatType,
returns = FloatType
) { phase, threshold ->
step(threshold, smoothstep(0.08f, 0.92f, fract(phase)))
}
val base = let(sample(), "base")
val uv = let(normalizedUv(), "uv")
val grid = let(gridMask(uv, densityUniform, lineWidthUniform), "grid")
val scan = let(scanlines(fragCoord.y, 14f, 3f), "scan")
val pulse = let(pulseBand(uv.y * densityUniform * 0.5f + grid * 0.35f, 0.55f), "pulse")
val hardMask = let(step(0.45f, scan * pulse), "hard_mask")
val active = let((grid gt 0.05f) or (hardMask gt 0.5f), "active")
val accent = let(color(float3(0.05f, 0.95f, 0.82f), base.a), "accent")
val mixed = let(mix(base, accent, min(grid * 0.85f + hardMask * 0.35f, 1f)), "mixed")
ifElse(active, mix(base, mixed, amountUniform), base)
}
fn() extracts custom functions, gridMask() and scanlines() from the stdlib simplify masks. ifElse() keeps things declarative.
3. Radar: Polar Coordinates
val effect = redbytefx {
val time by autoUniformTime()
val speed by autoUniformFloat(0.72f)
val radius by autoUniformFloat(0.34f)
val amount by autoUniformFloat(0.86f)
val base = let(sample(), "base")
val uv = let(fragCoord / resolution, "uv")
val polar = let(polarCoordinates(uv), "polar")
val sweepAngle = let(fract(time * speed * 0.08f), "sweep_angle")
val sweep = let(angularSweep(uv = uv, angle = sweepAngle, width = 0.12f, feather = 0.03f), "sweep")
val arc = let(
arcMask(
uv = uv,
radius = radius,
ringWidth = 0.09f,
angle = sweepAngle,
arcWidth = 0.18f,
feather = 0.03f
),
"arc"
)
val outerRing = let(ringMask(uv, radius = radius, width = 0.016f, feather = 0.012f), "outer_ring")
val innerRing = let(ringMask(uv, radius = max(radius * 0.58f, 0.08f), width = 0.014f, feather = 0.012f), "inner_ring")
val beam = let(radialRamp(uv = uv, innerRadius = float(0.06f), outerRadius = radius + 0.18f), "beam")
val mask = let(max(max(sweep * beam, arc), max(outerRing, innerRing)), "mask")
val tint = let(
color(
mix(0.05f, 0.18f, polar.x * 1.4f),
mix(0.24f, 1f, sweep + arc * 0.55f),
mix(0.10f, 0.62f, polar.y * 0.45f + outerRing * 0.35f),
base.a
),
"tint"
)
val screened = let(maskedScreen(base, tint, mask, amount), "screened")
maskedOverlay(screened, color(float3(0.82f, 1f, 0.72f), base.a), arc, amount * 0.32f)
}
polarCoordinates(), arcMask(), and ringMask() from stdlib replace manual smoothstep chains. maskedScreen() and maskedOverlay() streamline blending.
4. Metaballs: Procedural Shapes
This effect builds SDF circles with smoothed min for neon blobs. DSL handles full procedural effects while keeping complex geometry readable.
Key Takeaways
- Kotlin DSL generates native AGSL with zero overhead—verify via agslSource().
- Typed uniforms and let() fix string-based code and refactoring woes.
- Standard library (gridMask, polarCoordinates, arcMask) saves time on primitives.
- fn() and ifElse() enable modularity without going imperative.
- Compose integration via autoUniformTime() and parameter binding.
— Editorial Team
No comments yet.