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.Modifier
|
||||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||||
import androidx.compose.ui.draw.blur
|
import androidx.compose.ui.draw.blur
|
||||||
|
import androidx.compose.ui.draw.drawWithContent
|
||||||
import androidx.compose.ui.draw.scale
|
import androidx.compose.ui.draw.scale
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.graphics.BlendMode
|
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.BlobAmber
|
||||||
import dev.kaizen.app.ui.theme.BlobBlue
|
import dev.kaizen.app.ui.theme.BlobBlue
|
||||||
import dev.kaizen.app.ui.theme.BlobTeal
|
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. */
|
/** Mesh/blob background: large, heavily blurred color circles that drift slowly. */
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -66,7 +69,17 @@ fun MeshBackground(content: @Composable BoxScope.() -> Unit) {
|
||||||
animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse),
|
animationSpec = infiniteRepeatable(tween(22000, easing = LinearEasing), RepeatMode.Reverse),
|
||||||
label = "drift",
|
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(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(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)
|
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