Skip to content

Instantly share code, notes, and snippets.

@zach-klippenstein
Created April 12, 2022 06:40
Show Gist options
  • Star 4 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save zach-klippenstein/bf121a54917c688f04584475f3fb11aa to your computer and use it in GitHub Desktop.
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.
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