Writing your own dither
Implement DitherField for any shape you can describe with maths, then make it fast with a shader.
Most new looks are a new field. A field is one function, so a new look costs a few lines.
val spiral = DitherField { x, y, t, aspect ->
val dx = (x - 0.5f) * aspect
val dy = y - 0.5f
0.5f + 0.5f * sin(hypot(dx, dy) * 14f - atan2(dy, dx) * 2f - t * 2f)
}
Box(Modifier.size(240.dp).dither(spiral, DitherPatterns.Bayer8, animated = true, stillTime = 1f))
Rules for a good field
- Return smooth tones. Let the pattern make the dots. A field that returns only 0 and 1 will look like a plain shape.
- Scale
xbyaspectbefore you measure a distance, or circles turn into ellipses on wide elements. - Stay pure. No state, no allocation per call, no platform code. A field is called once per cell per frame.
- Reach 0 and 1. If the range is narrower the dither looks washed out. Values outside 0..1 are clamped.
- Pick a still frame. For an animated field choose
stillTimeso the frozen picture looks intentional.
Give it a shader
For a large surface the cell renderer is slow. Implement ShaderField and the shader renderer can draw your field in one pass:
object Plasma : ShaderField {
override val shaderSource = """
float tone(float2 uv, float t, float aspect) {
float2 p = float2(uv.x * aspect, uv.y) * 6.0;
return (sin(p.x + t) + sin(p.y * 1.3 - t * 0.7) + 2.0) / 4.0;
}
""".trimIndent()
override fun tone(x: Float, y: Float, t: Float, aspect: Float): Float {
val u = x * aspect * 6f
val v = y * 6f
return (sin(u + t) + sin(v * 1.3f - t * 0.7f) + 2f) / 4f
}
}
The shader source is a function named tone in the language AGSL and SkSL share. Keep it equal to the Kotlin version, because the renderer is chosen per device: a fallback to cells must not change the picture. See choosing a renderer.