fix(ui): dither gradients to kill 8-bit sRGB banding on orb + mesh

This commit is contained in:
Bruno Deanoz 2026-06-19 07:27:33 +02:00
parent 44c17b6f9f
commit aa0a97b959
2 changed files with 66 additions and 1 deletions

View file

@ -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)

View file

@ -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.0120.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)) }
}