Created
April 12, 2022 06:40
-
-
Save zach-klippenstein/bf121a54917c688f04584475f3fb11aa to your computer and use it in GitHub Desktop.
Demo app with a modifier that renders its node as comic book-style dots.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import androidx.compose.desktop.ui.tooling.preview.Preview | |
import androidx.compose.foundation.Canvas | |
import androidx.compose.foundation.layout.Column | |
import androidx.compose.foundation.layout.Row | |
import androidx.compose.foundation.layout.fillMaxSize | |
import androidx.compose.material.Checkbox | |
import androidx.compose.material.MaterialTheme | |
import androidx.compose.material.Surface | |
import androidx.compose.material.Text | |
import androidx.compose.runtime.* | |
import androidx.compose.ui.Alignment.Companion.CenterHorizontally | |
import androidx.compose.ui.Alignment.Companion.CenterVertically | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.composed | |
import androidx.compose.ui.draw.clipToBounds | |
import androidx.compose.ui.geometry.Offset | |
import androidx.compose.ui.geometry.Size | |
import androidx.compose.ui.geometry.isSpecified | |
import androidx.compose.ui.graphics.Color | |
import androidx.compose.ui.graphics.asComposeRenderEffect | |
import androidx.compose.ui.graphics.colorspace.ColorSpaces | |
import androidx.compose.ui.graphics.graphicsLayer | |
import androidx.compose.ui.input.pointer.pointerInput | |
import androidx.compose.ui.layout.onSizeChanged | |
import androidx.compose.ui.window.Window | |
import androidx.compose.ui.window.application | |
import org.intellij.lang.annotations.Language | |
import org.jetbrains.skia.ImageFilter | |
import org.jetbrains.skia.RuntimeEffect | |
import org.jetbrains.skia.RuntimeShaderBuilder | |
import kotlin.random.Random | |
fun main() = application { | |
Window( | |
title = "Comic Filter", | |
onCloseRequest = ::exitApplication | |
) { | |
App() | |
} | |
} | |
@Composable | |
@Preview | |
fun App() { | |
var comicEnabled by remember { mutableStateOf(false) } | |
MaterialTheme { | |
Surface { | |
Column(horizontalAlignment = CenterHorizontally) { | |
Row(verticalAlignment = CenterVertically) { | |
Text("Enable comic book effect? ") | |
Checkbox(comicEnabled, onCheckedChange = { comicEnabled = it }) | |
} | |
Canvas( | |
Modifier | |
.fillMaxSize() | |
.clipToBounds() | |
.then( | |
if (comicEnabled) Modifier.comicBookEffect( | |
tileSize = 12f, | |
dotDiameter = 9f, | |
maxRedShift = Offset(-2f, 3f), | |
maxGreenShift = Offset(2.5f, 0f), | |
maxBlueShift = Offset(1f, -2f), | |
baseColor = Color.White | |
) else Modifier | |
) | |
) { | |
// Draw something interesting. | |
val random = Random(seed = 0) | |
repeat(((size.width * size.height) / 10000).toInt()) { | |
drawCircle( | |
color = random.nextColor(), | |
radius = random.nextInt(25, 150).toFloat(), | |
center = random.nextOffset(size), | |
alpha = 0.8f | |
) | |
} | |
} | |
} | |
} | |
} | |
} | |
@Language("GLSL") | |
private val comicShaderSksl = """ | |
uniform shader content; | |
uniform float tileSize; | |
uniform float dotMinDiameter; | |
uniform vec2 maxRedShift; | |
uniform vec2 maxGreenShift; | |
uniform vec2 maxBlueShift; | |
uniform half3 baseColor; | |
uniform vec2 focalPoint; | |
uniform float focalSizeCutoff; | |
uniform float focalChromaticCutoff; | |
half4 main(vec2 fragcoord) { | |
vec2 tileIndex = vec2( | |
floor(fragcoord.x / tileSize), | |
floor(fragcoord.y / tileSize) | |
); | |
vec2 tileOrigin = tileIndex * tileSize; | |
vec2 tileCoord = fragcoord - tileOrigin; | |
vec2 tileCenter = vec2(tileSize / 2, tileSize / 2); | |
// Take a sample of the source's color from the center of the tile. | |
vec2 sampleCoord = tileOrigin + tileCenter; | |
half4 sampleColor = content.eval(sampleCoord); | |
// Modulate the size of the dot by the mouse position. | |
float distFromMouse = distance(fragcoord, focalPoint.xy); | |
float dotRadius = max( | |
dotMinDiameter / 2, | |
mix(tileSize / 2, 0, distFromMouse / focalSizeCutoff) | |
); | |
// Chromatic aberration | |
float sampleRed = sampleColor.r; | |
float sampleGreen = sampleColor.g; | |
float sampleBlue = sampleColor.b; | |
// …also modulated by mouse position. | |
vec2 redShift = maxRedShift * distFromMouse / focalChromaticCutoff; | |
vec2 greenShift = maxGreenShift * distFromMouse / focalChromaticCutoff; | |
vec2 blueShift = maxBlueShift * distFromMouse / focalChromaticCutoff; | |
float distRed = distance(tileCoord, tileCenter + redShift); | |
float distGreen = distance(tileCoord, tileCenter + greenShift); | |
float distBlue = distance(tileCoord, tileCenter + blueShift); | |
return half4( | |
distRed <= dotRadius ? sampleRed : baseColor.r, | |
distGreen <= dotRadius ? sampleGreen : baseColor.g, | |
distBlue <= dotRadius ? sampleBlue : baseColor.b, | |
sampleColor.a | |
); | |
} | |
""".trimIndent() | |
private val comicRuntimeEffect = RuntimeEffect.makeForShader(comicShaderSksl) | |
fun Modifier.comicBookEffect( | |
tileSize: Float, | |
dotDiameter: Float, | |
maxRedShift: Offset, | |
maxGreenShift: Offset, | |
maxBlueShift: Offset, | |
baseColor: Color | |
): Modifier = composed { | |
var mousePosition by remember { mutableStateOf(Offset.Unspecified) } | |
var maxDimension by remember { mutableStateOf(Float.POSITIVE_INFINITY) } | |
Modifier | |
.pointerInput(Unit) { | |
awaitPointerEventScope { | |
while (true) { | |
val event = awaitPointerEvent() | |
mousePosition = event.changes.last().position | |
} | |
} | |
} | |
.onSizeChanged { maxDimension = maxOf(it.width, it.height).toFloat() } | |
.graphicsLayer { | |
renderEffect = makeComicImageFilter( | |
tileSize = tileSize, | |
dotDiameter = dotDiameter, | |
maxRedShift = maxRedShift, | |
maxGreenShift = maxGreenShift, | |
maxBlueShift = maxBlueShift, | |
baseColor = baseColor, | |
focalPoint = mousePosition, | |
focalSizeCutoff = maxDimension / 2f, | |
focalChromaticCutoff = maxDimension | |
).asComposeRenderEffect() | |
} | |
} | |
private fun makeComicImageFilter( | |
tileSize: Float, | |
dotDiameter: Float, | |
maxRedShift: Offset, | |
maxGreenShift: Offset, | |
maxBlueShift: Offset, | |
baseColor: Color, | |
focalPoint: Offset = Offset.Unspecified, | |
focalSizeCutoff: Float = Float.POSITIVE_INFINITY, | |
focalChromaticCutoff: Float = Float.POSITIVE_INFINITY, | |
): ImageFilter = ImageFilter.makeRuntimeShader( | |
runtimeShaderBuilder = RuntimeShaderBuilder(comicRuntimeEffect).apply { | |
uniform("tileSize", tileSize) | |
uniform("dotMinDiameter", dotDiameter) | |
uniform("maxRedShift", maxRedShift.x, maxRedShift.y) | |
uniform("maxGreenShift", maxGreenShift.x, maxGreenShift.y) | |
uniform("maxBlueShift", maxBlueShift.x, maxBlueShift.y) | |
baseColor.convert(ColorSpaces.LinearSrgb).let { | |
uniform("baseColor", it.red, it.green, it.blue) | |
} | |
if (focalPoint.isSpecified) { | |
uniform("focalPoint", focalPoint.x, focalPoint.y) | |
} | |
uniform("focalSizeCutoff", focalSizeCutoff) | |
uniform("focalChromaticCutoff", focalChromaticCutoff) | |
}, | |
shaderName = "content", | |
input = null | |
) | |
private fun Random.nextOffset(bounds: Size) = Offset( | |
x = nextInt(bounds.width.toInt()).toFloat(), | |
y = nextInt(bounds.height.toInt()).toFloat(), | |
) | |
private fun Random.nextColor() = Color( | |
red = nextInt(255), | |
green = nextInt(255), | |
blue = nextInt(255) | |
) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment