From aa0a97b9596d5871fc45f247b61a63fb099d7645 Mon Sep 17 00:00:00 2001 From: Bruno Deanoz Date: Fri, 19 Jun 2026 07:27:33 +0200 Subject: [PATCH] fix(ui): dither gradients to kill 8-bit sRGB banding on orb + mesh --- .../java/dev/kaizen/app/chat/Background.kt | 15 +++++- .../java/dev/kaizen/app/ui/theme/Dither.kt | 52 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/dev/kaizen/app/ui/theme/Dither.kt diff --git a/app/src/main/java/dev/kaizen/app/chat/Background.kt b/app/src/main/java/dev/kaizen/app/chat/Background.kt index eef941f..e83d7ad 100644 --- a/app/src/main/java/dev/kaizen/app/chat/Background.kt +++ b/app/src/main/java/dev/kaizen/app/chat/Background.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.BlurredEdgeTreatment import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.scale import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode @@ -51,6 +52,8 @@ import dev.kaizen.app.ui.theme.AmberDeep import dev.kaizen.app.ui.theme.BlobAmber import dev.kaizen.app.ui.theme.BlobBlue import dev.kaizen.app.ui.theme.BlobTeal +import dev.kaizen.app.ui.theme.DITHER_ALPHA +import dev.kaizen.app.ui.theme.rememberDitherBrush /** Mesh/blob background: large, heavily blurred color circles that drift slowly. */ @Composable @@ -66,7 +69,17 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) { animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse), label = "drift", ) - Box(Modifier.fillMaxSize().background(base)) { + // Dither overlay (drawn last, over everything) — kills 8-bit gradient banding. + val ditherBrush = rememberDitherBrush() + Box( + Modifier + .fillMaxSize() + .background(base) + .drawWithContent { + drawContent() + drawRect(brush = ditherBrush, alpha = DITHER_ALPHA) + }, + ) { Blob(BlobAmber, 360.dp, (-60f + drift * 30f).dp, (-40f + drift * 24f).dp, 0.30f * factor) Blob(BlobBlue, 380.dp, (170f - drift * 44f).dp, (150f + drift * 40f).dp, 0.24f * factor) Blob(BlobTeal, 300.dp, (30f + drift * 50f).dp, (560f - drift * 36f).dp, 0.22f * factor) diff --git a/app/src/main/java/dev/kaizen/app/ui/theme/Dither.kt b/app/src/main/java/dev/kaizen/app/ui/theme/Dither.kt new file mode 100644 index 0000000..286b893 --- /dev/null +++ b/app/src/main/java/dev/kaizen/app/ui/theme/Dither.kt @@ -0,0 +1,52 @@ +package dev.kaizen.app.ui.theme + +import android.graphics.Bitmap +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.ImageShader +import androidx.compose.ui.graphics.ShaderBrush +import androidx.compose.ui.graphics.TileMode +import androidx.compose.ui.graphics.asImageBitmap +import kotlin.random.Random + +/** + * Cross-version gradient dither. + * + * 8-bit sRGB radial gradients quantize into visible concentric rings (the + * "verpickelt"/banding look on the orb and mesh blobs). Overlaying a tiled, + * ~1-LSB black/white/transparent noise pattern trades those hard bands for + * imperceptible fine grain — the standard fix that works on every API level + * (no AGSL / API-33 gating needed, so it covers minSdk 26). + * + * The pattern is deterministic (fixed seed) so it never shimmers between + * recompositions; ~⅓ black, ⅓ white, ⅓ transparent makes the perturbation + * symmetric (darkens and brightens equally) at a low overall [DITHER_ALPHA]. + * + * [DITHER_ALPHA] is the single tuning knob: higher = bands vanish faster but + * fine grain creeps in (worst on the near-black Obsidian background); lower = + * cleaner but bands may survive. ~0.012–0.018 is the sweet spot — dial on device. + */ +const val DITHER_ALPHA = 0.015f + +private fun buildNoiseBitmap(size: Int = 160): ImageBitmap { + val pixels = IntArray(size * size) + val rnd = Random(0x4A1B) + for (i in pixels.indices) { + pixels[i] = when (rnd.nextInt(3)) { + 0 -> 0xFF000000.toInt() // darken + 1 -> 0xFFFFFFFF.toInt() // brighten + else -> 0x00000000 // leave untouched + } + } + val bmp = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + bmp.setPixels(pixels, 0, size, 0, 0, size, size) + return bmp.asImageBitmap() +} + +/** A tiled noise [ShaderBrush] for dithering. Build once, fill the whole surface at [DITHER_ALPHA]. */ +@Composable +fun rememberDitherBrush(): ShaderBrush { + val image = remember { buildNoiseBitmap() } + return remember(image) { ShaderBrush(ImageShader(image, TileMode.Repeated, TileMode.Repeated)) } +}