fix(ui): dither gradients to kill 8-bit sRGB banding on orb + mesh
This commit is contained in:
parent
44c17b6f9f
commit
aa0a97b959
2 changed files with 66 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
52
app/src/main/java/dev/kaizen/app/ui/theme/Dither.kt
Normal file
52
app/src/main/java/dev/kaizen/app/ui/theme/Dither.kt
Normal 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.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)) }
|
||||
}
|
||||
Loading…
Reference in a new issue