Skip to content
Stipple
Esc
↑↓navigate↵open⌘Jpreview
On this page

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 x by aspect before 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 stillTime so 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.

Was this page helpful?