Skip to content

Instantly share code, notes, and snippets.

@ardakazanci
Created July 12, 2026 10:51
Show Gist options
  • Select an option

  • Save ardakazanci/d5a86d50cdade83bd93239c7da7cde1f to your computer and use it in GitHub Desktop.

Select an option

Save ardakazanci/d5a86d50cdade83bd93239c7da7cde1f to your computer and use it in GitHub Desktop.
Jetpack Compose Gummy Effect
private data class GummyFlavor(
val name: String,
val color: Color,
)
private val gummyFlavors = listOf(
GummyFlavor("Raspberry", Color(0xFFFF315F)),
GummyFlavor("Tangerine", Color(0xFFFF8A24)),
GummyFlavor("Grape", Color(0xFF9B5CFF)),
)
private val fallingGummyColors = listOf(
Color(0xFFFF8A24),
Color(0xFFFF315F),
Color(0xFFFFD24A),
Color(0xFF5FE36A),
Color(0xFF35D4FF),
Color(0xFF9B5CFF),
Color(0xFFFF62C7),
Color(0xFFFF5F46),
Color(0xFF00D39B),
)
private val DefaultIsometricControl = Offset(0.38f, -0.72f)
private class IsometricMotionState {
val x = Animatable(DefaultIsometricControl.x)
val y = Animatable(DefaultIsometricControl.y)
val dragControl = mutableStateOf(DefaultIsometricControl)
val isDragging = mutableStateOf(false)
init {
x.updateBounds(-1f, 1f)
y.updateBounds(-1f, 1f)
}
fun currentControl(): Offset = if (isDragging.value) {
dragControl.value
} else {
Offset(x.value, y.value)
}
fun currentVelocity(): Offset = if (isDragging.value) {
Offset.Zero
} else {
Offset(x.velocity, y.velocity)
}
}
private class GummyPhysicsRuntime {
val world = GummyPhysicsWorld()
val frame = mutableLongStateOf(0L)
}
private data class GummyCollider(
val x: Float,
val y: Float,
val radius: Float,
)
private val gummyBearColliders = listOf(
GummyCollider(x = -0.31f, y = -0.69f, radius = 0.17f),
GummyCollider(x = 0.31f, y = -0.69f, radius = 0.17f),
GummyCollider(x = 0f, y = -0.38f, radius = 0.42f),
GummyCollider(x = 0f, y = 0.02f, radius = 0.42f),
GummyCollider(x = 0f, y = 0.43f, radius = 0.38f),
GummyCollider(x = -0.40f, y = 0.16f, radius = 0.20f),
GummyCollider(x = 0.40f, y = 0.16f, radius = 0.20f),
GummyCollider(x = -0.24f, y = 0.78f, radius = 0.25f),
GummyCollider(x = 0.24f, y = 0.78f, radius = 0.25f),
)
private data class FallingGummy(
val color: Color,
val radius: Float,
val mass: Float,
var x: Float,
var y: Float,
var vx: Float,
var vy: Float,
var angle: Float,
var angularVelocity: Float,
var deformation: Float = 0f,
var deformationVelocity: Float = 0f,
var impactX: Float = x,
var impactY: Float = y + radius,
var impactNormalX: Float = 0f,
var impactNormalY: Float = -1f,
var impactStrength: Float = 0f,
) {
val inverseMass: Float = 1f / mass
val inverseInertia: Float = 1f / (mass * radius * radius * 0.56f)
}
private class GummyPhysicsWorld {
private val random = Random(28_437)
private var accumulatorSeconds = 0f
private var hapticCooldownSeconds = 0f
var hapticEventSerial: Long = 0L
private set
val bodies = mutableListOf<FallingGummy>()
fun seed(width: Float, height: Float, cascadeKey: Int) {
bodies.clear()
accumulatorSeconds = 0f
hapticCooldownSeconds = 0f
hapticEventSerial = 0L
val unit = min(width, height)
val left = unit * 0.16f
val right = width - unit * 0.16f
val seededRandom = Random(7_700 + cascadeKey * 97)
repeat(10) { index ->
val radius = unit * (0.104f + seededRandom.nextFloat() * 0.026f)
val x = left + seededRandom.nextFloat() * max(1f, right - left)
val y = -radius * (1.7f + index * 0.92f) - seededRandom.nextFloat() * unit * 0.22f
val vx = (seededRandom.nextFloat() - 0.5f) * unit * 0.72f
val vy = seededRandom.nextFloat() * unit * 0.15f
val angularVelocity = (seededRandom.nextFloat() - 0.5f) * 2.4f
bodies += FallingGummy(
color = fallingGummyColors[index % fallingGummyColors.size],
radius = radius,
mass = radius * radius,
x = x,
y = y,
vx = vx,
vy = vy,
angle = seededRandom.nextFloat() * 6.28318f,
angularVelocity = angularVelocity,
)
}
}
fun step(width: Float, height: Float, dtSeconds: Float) {
if (bodies.isEmpty()) return
val fixedStep = 1f / 120f
accumulatorSeconds = min(accumulatorSeconds + dtSeconds.coerceIn(0f, 1f / 20f), 1f / 15f)
var steps = 0
while (accumulatorSeconds >= fixedStep && steps < 8) {
simulateFixedStep(width, height, fixedStep)
accumulatorSeconds -= fixedStep
steps++
}
}
fun drawOrder(): List<FallingGummy> = bodies.sortedBy { it.y + it.radius * 0.18f }
private fun simulateFixedStep(width: Float, height: Float, dt: Float) {
val unit = min(width, height)
val gravity = height * 1.24f
val floor = height - unit * 0.405f
val ceiling = height * 0.245f
hapticCooldownSeconds = max(0f, hapticCooldownSeconds - dt)
bodies.forEach { body ->
body.vy += gravity * dt
body.vx *= 0.9985f
body.vy *= 0.9992f
body.angularVelocity *= 0.997f
body.x += body.vx * dt
body.y += body.vy * dt
body.angle += body.angularVelocity * dt
// Underdamped viscoelastic response: impacts compress, then the candy
// briefly overshoots before returning to its molded shape.
body.deformationVelocity += (
-body.deformation * 170f - body.deformationVelocity * 12.5f
) * dt
body.deformation = (
body.deformation + body.deformationVelocity * dt
).coerceIn(-0.16f, 0.58f)
body.impactStrength = max(0f, body.impactStrength - dt * 2.8f)
}
repeat(7) {
solveWalls(width, floor, ceiling)
solvePairs()
}
bodies.forEach { body ->
if (abs(body.vx) < 0.08f) body.vx = 0f
if (abs(body.vy) < 0.08f) body.vy = 0f
if (abs(body.angularVelocity) < 0.0008f) body.angularVelocity = 0f
}
}
private fun solveWalls(width: Float, floor: Float, ceiling: Float) {
bodies.forEach { body ->
var leftPenetration = 0f
var rightPenetration = 0f
var topPenetration = 0f
var floorPenetration = 0f
var leftContactY = body.y
var rightContactY = body.y
var topContactX = body.x
var floorContactX = body.x
val c = cos(body.angle)
val s = sin(body.angle)
gummyBearColliders.forEach { collider ->
val localX = collider.x * body.radius
val localY = collider.y * body.radius
val worldX = body.x + localX * c - localY * s
val worldY = body.y + localX * s + localY * c
val colliderRadius = collider.radius * body.radius
val left = colliderRadius - worldX
if (left > leftPenetration) {
leftPenetration = left
leftContactY = worldY
}
val right = worldX + colliderRadius - width
if (right > rightPenetration) {
rightPenetration = right
rightContactY = worldY
}
val top = ceiling - (worldY - colliderRadius)
if (top > topPenetration) {
topPenetration = top
topContactX = worldX
}
val bottom = worldY + colliderRadius - floor
if (bottom > floorPenetration) {
floorPenetration = bottom
floorContactX = worldX
}
}
if (leftPenetration > 0f) {
resolveStaticContact(body, 1f, 0f, 0f, leftContactY, leftPenetration, 0.10f, 0.48f)
}
if (rightPenetration > 0f) {
resolveStaticContact(body, -1f, 0f, width, rightContactY, rightPenetration, 0.10f, 0.48f)
}
if (topPenetration > 0f) {
resolveStaticContact(body, 0f, 1f, topContactX, ceiling, topPenetration, 0.04f, 0.34f)
}
if (floorPenetration > 0f) {
resolveStaticContact(body, 0f, -1f, floorContactX, floor, floorPenetration, 0.12f, 0.64f)
}
}
}
private fun resolveStaticContact(
body: FallingGummy,
nx: Float,
ny: Float,
contactX: Float,
contactY: Float,
penetration: Float,
restitution: Float,
friction: Float,
) {
val correction = max(0f, penetration - 0.22f) * 0.74f
body.x += nx * correction
body.y += ny * correction
val rx = contactX - body.x
val ry = contactY - body.y
val contactVx = body.vx - body.angularVelocity * ry
val contactVy = body.vy + body.angularVelocity * rx
val normalVelocity = contactVx * nx + contactVy * ny
if (normalVelocity >= 0f) return
val rn = rx * ny - ry * nx
val denominator = body.inverseMass + rn * rn * body.inverseInertia
val impulse = -(1f + restitution) * normalVelocity / max(denominator, 0.000001f)
applyImpulse(body, nx * impulse, ny * impulse, rx, ry)
val tx = -ny
val ty = nx
val postVx = body.vx - body.angularVelocity * ry
val postVy = body.vy + body.angularVelocity * rx
val tangentVelocity = postVx * tx + postVy * ty
val rt = rx * ty - ry * tx
val tangentDenominator = body.inverseMass + rt * rt * body.inverseInertia
val rawFrictionImpulse = -tangentVelocity / max(tangentDenominator, 0.000001f)
val frictionImpulse = rawFrictionImpulse.coerceIn(-impulse * friction, impulse * friction)
applyImpulse(body, tx * frictionImpulse, ty * frictionImpulse, rx, ry)
registerImpact(
body = body,
contactX = contactX,
contactY = contactY,
normalX = nx,
normalY = ny,
closingSpeed = -normalVelocity,
penetration = penetration,
)
}
private fun solvePairs() {
for (i in 0 until bodies.lastIndex) {
val a = bodies[i]
for (j in i + 1 until bodies.size) {
val b = bodies[j]
val broadDx = b.x - a.x
val broadDy = b.y - a.y
val broadRadius = (a.radius + b.radius) * 1.08f
if (broadDx * broadDx + broadDy * broadDy > broadRadius * broadRadius) continue
resolveBearPair(a, b)
}
}
}
private fun resolveBearPair(a: FallingGummy, b: FallingGummy) {
val cosA = cos(a.angle)
val sinA = sin(a.angle)
val cosB = cos(b.angle)
val sinB = sin(b.angle)
var bestPenetration = 0f
var bestAx = 0f
var bestAy = 0f
var bestBx = 0f
var bestBy = 0f
var bestAr = 0f
var bestBr = 0f
gummyBearColliders.forEach { colliderA ->
val localAx = colliderA.x * a.radius
val localAy = colliderA.y * a.radius
val ax = a.x + localAx * cosA - localAy * sinA
val ay = a.y + localAx * sinA + localAy * cosA
val ar = colliderA.radius * a.radius
gummyBearColliders.forEach { colliderB ->
val localBx = colliderB.x * b.radius
val localBy = colliderB.y * b.radius
val bx = b.x + localBx * cosB - localBy * sinB
val by = b.y + localBx * sinB + localBy * cosB
val br = colliderB.radius * b.radius
val dx = bx - ax
val dy = by - ay
val combinedRadius = ar + br
val distanceSquared = dx * dx + dy * dy
if (distanceSquared >= combinedRadius * combinedRadius) return@forEach
val penetration = combinedRadius - sqrt(max(distanceSquared, 0.0001f))
if (penetration > bestPenetration) {
bestPenetration = penetration
bestAx = ax
bestAy = ay
bestBx = bx
bestBy = by
bestAr = ar
bestBr = br
}
}
}
if (bestPenetration <= 0f) return
var dx = bestBx - bestAx
var dy = bestBy - bestAy
var distance = sqrt(dx * dx + dy * dy)
if (distance < 0.001f) {
dx = (random.nextFloat() - 0.5f) * 0.02f
dy = -0.02f
distance = sqrt(dx * dx + dy * dy)
}
val nx = dx / distance
val ny = dy / distance
val invMassSum = a.inverseMass + b.inverseMass
val correction = max(0f, bestPenetration - 0.18f) / max(invMassSum, 0.000001f) * 0.66f
a.x -= nx * correction * a.inverseMass
a.y -= ny * correction * a.inverseMass
b.x += nx * correction * b.inverseMass
b.y += ny * correction * b.inverseMass
val contactX = bestAx + nx * (bestAr - bestPenetration * 0.5f)
val contactY = bestAy + ny * (bestAr - bestPenetration * 0.5f)
val rax = contactX - a.x
val ray = contactY - a.y
val rbx = contactX - b.x
val rby = contactY - b.y
val avx = a.vx - a.angularVelocity * ray
val avy = a.vy + a.angularVelocity * rax
val bvx = b.vx - b.angularVelocity * rby
val bvy = b.vy + b.angularVelocity * rbx
val rvx = bvx - avx
val rvy = bvy - avy
val normalVelocity = rvx * nx + rvy * ny
if (normalVelocity >= 0f) return
val raCrossN = rax * ny - ray * nx
val rbCrossN = rbx * ny - rby * nx
val denominator = a.inverseMass + b.inverseMass +
raCrossN * raCrossN * a.inverseInertia +
rbCrossN * rbCrossN * b.inverseInertia
val closingSpeed = -normalVelocity
val restitution = if (closingSpeed > min(a.radius, b.radius) * 1.4f) 0.16f else 0.045f
val impulse = -(1f + restitution) * normalVelocity / max(denominator, 0.000001f)
val impulseX = nx * impulse
val impulseY = ny * impulse
applyImpulse(a, -impulseX, -impulseY, rax, ray)
applyImpulse(b, impulseX, impulseY, rbx, rby)
val postAvx = a.vx - a.angularVelocity * ray
val postAvy = a.vy + a.angularVelocity * rax
val postBvx = b.vx - b.angularVelocity * rby
val postBvy = b.vy + b.angularVelocity * rbx
val tangentX = -ny
val tangentY = nx
val tangentVelocity = (postBvx - postAvx) * tangentX + (postBvy - postAvy) * tangentY
val raCrossT = rax * tangentY - ray * tangentX
val rbCrossT = rbx * tangentY - rby * tangentX
val tangentDenominator = a.inverseMass + b.inverseMass +
raCrossT * raCrossT * a.inverseInertia +
rbCrossT * rbCrossT * b.inverseInertia
val rawFrictionImpulse = -tangentVelocity / max(tangentDenominator, 0.000001f)
val frictionImpulse = rawFrictionImpulse.coerceIn(-impulse * 0.52f, impulse * 0.52f)
applyImpulse(a, -tangentX * frictionImpulse, -tangentY * frictionImpulse, rax, ray)
applyImpulse(b, tangentX * frictionImpulse, tangentY * frictionImpulse, rbx, rby)
registerImpact(a, contactX, contactY, -nx, -ny, closingSpeed, bestPenetration)
registerImpact(b, contactX, contactY, nx, ny, closingSpeed, bestPenetration)
}
private fun applyImpulse(
body: FallingGummy,
impulseX: Float,
impulseY: Float,
rx: Float,
ry: Float,
) {
body.vx += impulseX * body.inverseMass
body.vy += impulseY * body.inverseMass
body.angularVelocity += (rx * impulseY - ry * impulseX) * body.inverseInertia
}
private fun registerImpact(
body: FallingGummy,
contactX: Float,
contactY: Float,
normalX: Float,
normalY: Float,
closingSpeed: Float,
penetration: Float,
) {
// Ignore the tiny support impulse caused by gravity while resting. Without
// this dead zone a settled pile would keep looking artificially agitated.
val effectiveImpactSpeed = max(0f, closingSpeed - body.radius * 0.30f)
val speedStrength = (effectiveImpactSpeed / max(body.radius * 3.7f, 1f)).coerceIn(0f, 0.42f)
val depthStrength = (penetration / max(body.radius, 1f) * 0.72f).coerceIn(0f, 0.34f)
val strength = max(speedStrength, depthStrength)
if (strength < 0.012f) return
if (strength >= body.impactStrength * 0.70f) {
body.impactX = contactX
body.impactY = contactY
body.impactNormalX = normalX
body.impactNormalY = normalY
}
body.impactStrength = max(body.impactStrength, strength)
body.deformation = max(body.deformation, depthStrength * 0.82f)
body.deformationVelocity += strength * 8.2f
body.deformationVelocity = body.deformationVelocity.coerceAtMost(5.2f)
if (strength > 0.17f && hapticCooldownSeconds <= 0f) {
hapticEventSerial += 1L
hapticCooldownSeconds = 0.18f
}
}
}
@Composable
private fun rememberGummyPhysicsRuntime(
cascadeKey: Int,
canvasSize: IntSize,
onStrongImpact: () -> Unit,
): GummyPhysicsRuntime {
val runtime = remember { GummyPhysicsRuntime() }
val latestOnStrongImpact by rememberUpdatedState(onStrongImpact)
LaunchedEffect(cascadeKey, canvasSize) {
if (canvasSize.width <= 0 || canvasSize.height <= 0) return@LaunchedEffect
val width = canvasSize.width.toFloat()
val height = canvasSize.height.toFloat()
runtime.world.seed(width, height, cascadeKey)
var lastFrameNanos = 0L
var lastHapticSerial = runtime.world.hapticEventSerial
while (true) {
withFrameNanos { frameNanos ->
if (lastFrameNanos != 0L) {
val dt = (frameNanos - lastFrameNanos) / 1_000_000_000f
runtime.world.step(width, height, dt)
if (runtime.world.hapticEventSerial != lastHapticSerial) {
lastHapticSerial = runtime.world.hapticEventSerial
latestOnStrongImpact()
}
runtime.frame.longValue += 1L
}
lastFrameNanos = frameNanos
}
}
}
return runtime
}
@Composable
fun GummyCandyScreen(modifier: Modifier = Modifier) {
var isPressed by remember { mutableStateOf(false) }
var selectedFlavor by remember { mutableIntStateOf(0) }
var cascadeKey by remember { mutableIntStateOf(0) }
var interactionGeneration by remember { mutableIntStateOf(0) }
val touchPosition = remember { mutableStateOf(Offset.Zero) }
val dragOffset = remember { mutableStateOf(Offset.Zero) }
val releaseTime = remember { mutableFloatStateOf(-100f) }
val press = remember { Animatable(0f) }
val creep = remember { Animatable(0f) }
val releaseImpulse = remember { Animatable(0f) }
val lightSweep = remember { Animatable(-1f) }
val isometricMotion = remember { IsometricMotionState() }
val interactionScope = rememberCoroutineScope()
val motion = rememberInfiniteTransition(label = "gummy-idle")
val time = motion.animateFloat(
initialValue = 0f,
targetValue = 10_000f,
animationSpec = infiniteRepeatable(animation = tween(durationMillis = 10_000_000)),
label = "gummy-shader-time",
)
val flavorTransition = updateTransition(
targetState = selectedFlavor,
label = "gummy-flavor-material",
)
val animatedColor = flavorTransition.animateColor(
transitionSpec = { tween(durationMillis = 420) },
label = "gummy-pigment",
) { flavorIndex ->
gummyFlavors[flavorIndex].color
}
val materialDensity = flavorTransition.animateFloat(
transitionSpec = { tween(durationMillis = 420) },
label = "gummy-optical-density",
) { flavorIndex ->
when (flavorIndex) {
0 -> 0.94f
1 -> 0.86f
else -> 1.12f
}
}
val materialGloss = flavorTransition.animateFloat(
transitionSpec = { tween(durationMillis = 420) },
label = "gummy-surface-gloss",
) { flavorIndex ->
when (flavorIndex) {
0 -> 1.04f
1 -> 0.92f
else -> 1.13f
}
}
val flavor = gummyFlavors[selectedFlavor]
LaunchedEffect(selectedFlavor, interactionGeneration) {
if (selectedFlavor != 2) return@LaunchedEffect
delay(3_000)
var direction = -1f
while (true) {
if (!isPressed) {
coroutineScope {
launch {
isometricMotion.x.animateTo(
targetValue = 0.54f * direction,
animationSpec = tween(durationMillis = 1_450),
)
}
launch {
isometricMotion.y.animateTo(
targetValue = -0.86f,
animationSpec = tween(durationMillis = 1_250),
)
}
launch {
lightSweep.snapTo(0f)
lightSweep.animateTo(1f, tween(durationMillis = 780))
lightSweep.snapTo(-1f)
}
}
direction *= -1f
}
delay(3_200)
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(Color(0xFF150B18))
.semantics { contentDescription = "Realistic ${flavor.name} gummy candy" }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = true)
val origin = down.position
interactionGeneration += 1
touchPosition.value = origin
dragOffset.value = Offset.Zero
isPressed = true
interactionScope.launch {
press.stop()
press.animateTo(1f, tween(durationMillis = 72))
}
interactionScope.launch {
creep.stop()
creep.animateTo(1f, tween(durationMillis = 360))
}
try {
while (true) {
val change = awaitPointerEvent().changes
.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
touchPosition.value = change.position
dragOffset.value = change.position - origin
}
} finally {
isPressed = false
releaseTime.floatValue = time.value
val dragEnergy = (dragOffset.value.getDistance() / 420f).coerceIn(0f, 0.45f)
interactionScope.launch {
press.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = 0.42f,
stiffness = 210f,
),
)
}
interactionScope.launch {
creep.animateTo(0f, tween(durationMillis = 280))
}
interactionScope.launch {
releaseImpulse.snapTo(0.50f + dragEnergy)
releaseImpulse.animateTo(0f, tween(durationMillis = 700))
}
}
}
},
) {
GummyArtwork(
color = animatedColor,
selectedFlavor = selectedFlavor,
cascadeKey = cascadeKey,
time = time,
pressProvider = { press.value },
creepProvider = { creep.value },
releaseImpulseProvider = { releaseImpulse.value },
lightSweepProvider = { lightSweep.value },
materialDensity = materialDensity,
materialGloss = materialGloss,
touchPosition = touchPosition,
dragOffset = dragOffset,
releaseTime = releaseTime,
isometricMotion = isometricMotion,
modifier = Modifier.fillMaxSize(),
)
Column(
modifier = Modifier
.align(Alignment.TopCenter)
.padding(top = 64.dp, start = 24.dp, end = 24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "GUMMY / 01",
color = Color.White.copy(alpha = 0.56f),
fontSize = 11.sp,
fontWeight = FontWeight.SemiBold,
letterSpacing = 3.sp,
)
Spacer(Modifier.height(10.dp))
}
FlavorSelector(
selectedFlavor = selectedFlavor,
onFlavorSelected = {
interactionGeneration += 1
if (it == 1) cascadeKey += 1
selectedFlavor = it
interactionScope.launch {
lightSweep.stop()
lightSweep.snapTo(0f)
lightSweep.animateTo(1f, tween(durationMillis = 680))
lightSweep.snapTo(-1f)
}
},
modifier = Modifier
.align(Alignment.BottomCenter)
.padding(bottom = 48.dp),
)
if (selectedFlavor == 2) {
IsometricViewJoystick(
motionState = isometricMotion,
onInteraction = { interactionGeneration += 1 },
modifier = Modifier
.align(Alignment.BottomEnd)
.padding(end = 22.dp, bottom = 146.dp),
)
}
}
}
@Composable
private fun IsometricViewJoystick(
motionState: IsometricMotionState,
onInteraction: () -> Unit,
modifier: Modifier = Modifier,
) {
val motionScope = rememberCoroutineScope()
val latestOnInteraction by rememberUpdatedState(onInteraction)
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = "3D DEPTH",
color = Color.White.copy(alpha = 0.56f),
fontSize = 9.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 1.6.sp,
)
Spacer(Modifier.height(8.dp))
Canvas(
modifier = Modifier
.size(104.dp)
.semantics { contentDescription = "Isometric camera gesture joystick" }
.pointerInput(motionState) {
fun controlFor(position: Offset): Offset {
val center = Offset(size.width * 0.5f, size.height * 0.5f)
val radius = min(size.width, size.height) * 0.36f
var x = (position.x - center.x) / max(radius, 1f)
var y = (position.y - center.y) / max(radius, 1f)
val distance = sqrt(x * x + y * y)
if (distance > 1f) {
x /= distance
y /= distance
}
return Offset(x, y)
}
awaitEachGesture {
val down = awaitFirstDown(requireUnconsumed = false)
latestOnInteraction()
down.consume()
val radius = max(min(size.width, size.height) * 0.36f, 1f)
val velocityTracker = VelocityTracker()
velocityTracker.addPosition(down.uptimeMillis, down.position)
val initial = controlFor(down.position)
motionState.isDragging.value = true
motionState.dragControl.value = initial
motionScope.launch {
motionState.x.stop()
motionState.y.stop()
}
while (true) {
val change = awaitPointerEvent().changes
.firstOrNull { it.id == down.id }
if (change == null || !change.pressed) break
change.consume()
velocityTracker.addPosition(change.uptimeMillis, change.position)
val control = controlFor(change.position)
motionState.dragControl.value = control
}
val velocity = velocityTracker.calculateVelocity()
val normalizedVelocityX = (velocity.x / radius).coerceIn(-5f, 5f)
val normalizedVelocityY = (velocity.y / radius).coerceIn(-5f, 5f)
val releasedControl = motionState.dragControl.value
val targetX = (
releasedControl.x + normalizedVelocityX * 0.12f
).coerceIn(-1f, 1f)
val targetY = (
releasedControl.y + normalizedVelocityY * 0.12f
).coerceIn(-1f, 1f)
motionScope.launch {
motionState.x.stop()
motionState.y.stop()
motionState.x.snapTo(releasedControl.x)
motionState.y.snapTo(releasedControl.y)
motionState.isDragging.value = false
coroutineScope {
launch {
motionState.x.animateTo(
targetValue = targetX,
animationSpec = spring(
dampingRatio = 0.68f,
stiffness = 118f,
),
initialVelocity = normalizedVelocityX,
)
}
launch {
motionState.y.animateTo(
targetValue = targetY,
animationSpec = spring(
dampingRatio = 0.68f,
stiffness = 118f,
),
initialVelocity = normalizedVelocityY,
)
}
}
}
}
},
) {
val value = motionState.currentControl()
val center = Offset(size.width * 0.5f, size.height * 0.5f)
val outerRadius = size.minDimension * 0.47f
val travelRadius = size.minDimension * 0.34f
val knobCenter = Offset(
x = center.x + value.x.coerceIn(-1f, 1f) * travelRadius,
y = center.y + value.y.coerceIn(-1f, 1f) * travelRadius,
)
drawCircle(Color.Black.copy(alpha = 0.34f), outerRadius, center + Offset(0f, 4.dp.toPx()))
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color(0x443D2348), Color(0xCC160C1B)),
center = center - Offset(10.dp.toPx(), 12.dp.toPx()),
radius = outerRadius,
),
radius = outerRadius,
center = center,
)
drawCircle(
color = Color.White.copy(alpha = 0.16f),
radius = outerRadius,
center = center,
style = Stroke(width = 1.dp.toPx()),
)
drawLine(
color = Color.White.copy(alpha = 0.09f),
start = Offset(center.x - travelRadius, center.y),
end = Offset(center.x + travelRadius, center.y),
strokeWidth = 1.dp.toPx(),
)
drawLine(
color = Color.White.copy(alpha = 0.09f),
start = Offset(center.x, center.y - travelRadius),
end = Offset(center.x, center.y + travelRadius),
strokeWidth = 1.dp.toPx(),
)
drawCircle(Color.Black.copy(alpha = 0.46f), 17.dp.toPx(), knobCenter + Offset(0f, 3.dp.toPx()))
drawCircle(
brush = Brush.radialGradient(
colors = listOf(Color(0xFFCBB1FF), Color(0xFF8D4CFF), Color(0xFF51218F)),
center = knobCenter - Offset(5.dp.toPx(), 6.dp.toPx()),
radius = 18.dp.toPx(),
),
radius = 17.dp.toPx(),
center = knobCenter,
)
drawCircle(
color = Color.White.copy(alpha = 0.46f),
radius = 17.dp.toPx(),
center = knobCenter,
style = Stroke(width = 1.dp.toPx()),
)
drawCircle(Color.White.copy(alpha = 0.58f), 3.5.dp.toPx(), knobCenter - Offset(5.dp.toPx(), 6.dp.toPx()))
}
}
}
@Composable
private fun FlavorSelector(
selectedFlavor: Int,
onFlavorSelected: (Int) -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier,
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
text = gummyFlavors[selectedFlavor].name.uppercase(),
color = Color.White.copy(alpha = 0.72f),
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
letterSpacing = 2.4.sp,
)
Spacer(Modifier.height(14.dp))
Row(horizontalArrangement = Arrangement.spacedBy(14.dp)) {
gummyFlavors.forEachIndexed { index, flavor ->
val selected = index == selectedFlavor
Box(
modifier = Modifier
.size(if (selected) 34.dp else 30.dp)
.border(
width = if (selected) 2.dp else 1.dp,
color = if (selected) Color.White else Color.White.copy(alpha = 0.16f),
shape = CircleShape,
)
.padding(4.dp)
.background(flavor.color, CircleShape)
.clickable(
role = Role.RadioButton,
onClickLabel = "Select ${flavor.name} flavor",
) { onFlavorSelected(index) },
)
}
}
}
}
@Composable
private fun GummyArtwork(
color: State<Color>,
selectedFlavor: Int,
cascadeKey: Int,
time: State<Float>,
pressProvider: () -> Float,
creepProvider: () -> Float,
releaseImpulseProvider: () -> Float,
lightSweepProvider: () -> Float,
materialDensity: State<Float>,
materialGloss: State<Float>,
touchPosition: State<Offset>,
dragOffset: State<Offset>,
releaseTime: State<Float>,
isometricMotion: IsometricMotionState,
modifier: Modifier = Modifier,
) {
if (selectedFlavor == 1) {
GummyPhysicsArtwork(
cascadeKey = cascadeKey,
time = time,
modifier = modifier,
)
} else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShaderArtwork(
color = color,
isometric = selectedFlavor == 2,
isometricMotion = isometricMotion,
time = time,
pressProvider = pressProvider,
creepProvider = creepProvider,
releaseImpulseProvider = releaseImpulseProvider,
lightSweepProvider = lightSweepProvider,
materialDensity = materialDensity,
materialGloss = materialGloss,
touchPosition = touchPosition,
dragOffset = dragOffset,
releaseTime = releaseTime,
modifier = modifier,
)
} else {
FallbackGummyArtwork(
color = color,
pressProvider = pressProvider,
modifier = modifier,
)
}
}
@Composable
private fun GummyPhysicsArtwork(
cascadeKey: Int,
time: State<Float>,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val onStrongImpact = remember(hapticFeedback) {
{
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
RuntimeShaderPhysicsArtwork(
cascadeKey = cascadeKey,
time = time,
onStrongImpact = onStrongImpact,
modifier = modifier,
)
} else {
FallbackGummyPhysicsArtwork(
cascadeKey = cascadeKey,
onStrongImpact = onStrongImpact,
modifier = modifier,
)
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
private fun RuntimeShaderPhysicsArtwork(
cascadeKey: Int,
time: State<Float>,
onStrongImpact: () -> Unit,
modifier: Modifier = Modifier,
) {
var canvasSize by remember { mutableStateOf(IntSize.Zero) }
val runtime = rememberGummyPhysicsRuntime(cascadeKey, canvasSize, onStrongImpact)
val shaders = remember { List(10) { RuntimeShader(GUMMY_SHADER) } }
val brushes = remember(shaders) { shaders.map { ShaderBrush(it) } }
Canvas(
modifier = modifier.onSizeChanged {
if (canvasSize != it) canvasSize = it
},
) {
runtime.frame.longValue
drawFallbackBackground()
runtime.world.drawOrder().forEachIndexed { index, gummy ->
val shader = shaders.getOrNull(index) ?: return@forEachIndexed
val brush = brushes.getOrNull(index) ?: return@forEachIndexed
val pad = gummy.radius * 1.52f
val impactAmount = max(
abs(gummy.deformation) * 1.9f,
gummy.impactStrength,
).coerceIn(0f, 0.72f)
shader.setFloatUniform("uResolution", size.width, size.height)
shader.setFloatUniform("uTime", time.value + index * 0.19f)
shader.setFloatUniform("uPress", impactAmount)
shader.setFloatUniform("uTouch", gummy.impactX, gummy.impactY)
shader.setFloatUniform("uDrag", 0f, 0f)
shader.setFloatUniform("uReleaseTime", -100f)
shader.setFloatUniform("uColor", gummy.color.red, gummy.color.green, gummy.color.blue)
shader.setFloatUniform("uObjectMode", 1f)
shader.setFloatUniform("uTransparent", 1f)
shader.setFloatUniform("uObjectCenter", gummy.x, gummy.y)
shader.setFloatUniform("uObjectRadius", gummy.radius)
shader.setFloatUniform("uObjectRotation", gummy.angle)
shader.setFloatUniform("uImpactNormal", gummy.impactNormalX, gummy.impactNormalY)
shader.setFloatUniform("uGelDeform", gummy.deformation)
shader.setFloatUniform("uIsometric", 0f)
shader.setFloatUniform("uIsoControl", 0f, 0f)
shader.setFloatUniform("uIsoVelocity", 0f, 0f)
shader.setFloatUniform("uCreep", 0f)
shader.setFloatUniform("uReleaseImpulse", 0f)
shader.setFloatUniform("uLightSweep", -1f)
shader.setFloatUniform("uMaterialDensity", 0.86f)
shader.setFloatUniform("uGloss", 0.92f)
drawRect(
brush = brush,
topLeft = Offset(gummy.x - pad, gummy.y - pad),
size = Size(pad * 2f, pad * 2f),
)
}
}
}
@Composable
private fun FallbackGummyPhysicsArtwork(
cascadeKey: Int,
onStrongImpact: () -> Unit,
modifier: Modifier = Modifier,
) {
var canvasSize by remember { mutableStateOf(IntSize.Zero) }
val runtime = rememberGummyPhysicsRuntime(cascadeKey, canvasSize, onStrongImpact)
Canvas(
modifier = modifier.onSizeChanged {
if (canvasSize != it) canvasSize = it
},
) {
runtime.frame.longValue
drawFallbackBackground()
runtime.world.drawOrder().forEach { gummy ->
val squash = abs(gummy.deformation).coerceIn(0f, 1f)
val scaleX = 1f + squash * 0.16f
val scaleY = 1f - squash * 0.12f
val bodySize = Size(gummy.radius * 1.18f * scaleX, gummy.radius * 1.62f * scaleY)
val bodyTopLeft = Offset(gummy.x - bodySize.width / 2f, gummy.y - bodySize.height * 0.42f)
drawOval(
color = Color.Black.copy(alpha = 0.26f),
topLeft = Offset(gummy.x - gummy.radius * 0.78f, gummy.y + gummy.radius * 0.86f),
size = Size(gummy.radius * 1.56f, gummy.radius * 0.36f),
)
drawOval(
brush = Brush.linearGradient(
colors = listOf(gummy.color.copy(alpha = 0.96f), gummy.color.copy(alpha = 0.62f)),
start = bodyTopLeft,
end = Offset(bodyTopLeft.x + bodySize.width, bodyTopLeft.y + bodySize.height),
),
topLeft = bodyTopLeft,
size = bodySize,
)
drawCircle(gummy.color.copy(alpha = 0.88f), gummy.radius * 0.43f, Offset(gummy.x, gummy.y - gummy.radius * 0.70f))
drawCircle(Color.White.copy(alpha = 0.30f), gummy.radius * 0.12f, Offset(gummy.x - gummy.radius * 0.18f, gummy.y - gummy.radius * 0.88f))
}
}
}
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@Composable
private fun RuntimeShaderArtwork(
color: State<Color>,
isometric: Boolean,
isometricMotion: IsometricMotionState,
time: State<Float>,
pressProvider: () -> Float,
creepProvider: () -> Float,
releaseImpulseProvider: () -> Float,
lightSweepProvider: () -> Float,
materialDensity: State<Float>,
materialGloss: State<Float>,
touchPosition: State<Offset>,
dragOffset: State<Offset>,
releaseTime: State<Float>,
modifier: Modifier = Modifier,
) {
val shader = remember { RuntimeShader(GUMMY_SHADER) }
val shaderBrush = remember(shader) { ShaderBrush(shader) }
Canvas(modifier = modifier) {
val pigment = color.value
val press = pressProvider()
val control = isometricMotion.currentControl()
val controlVelocity = isometricMotion.currentVelocity()
shader.setFloatUniform("uResolution", size.width, size.height)
shader.setFloatUniform("uTime", time.value)
shader.setFloatUniform("uPress", press)
shader.setFloatUniform("uTouch", touchPosition.value.x, touchPosition.value.y)
shader.setFloatUniform("uDrag", dragOffset.value.x, dragOffset.value.y)
shader.setFloatUniform("uReleaseTime", releaseTime.value)
shader.setFloatUniform("uColor", pigment.red, pigment.green, pigment.blue)
shader.setFloatUniform("uObjectMode", 0f)
shader.setFloatUniform("uTransparent", 0f)
shader.setFloatUniform("uObjectCenter", size.width * 0.5f, size.height * 0.5f)
shader.setFloatUniform("uObjectRadius", size.minDimension)
shader.setFloatUniform("uObjectRotation", 0f)
shader.setFloatUniform("uImpactNormal", 0f, -1f)
shader.setFloatUniform("uGelDeform", press)
shader.setFloatUniform("uIsometric", if (isometric) 1f else 0f)
shader.setFloatUniform(
"uIsoControl",
control.x,
control.y,
)
shader.setFloatUniform(
"uIsoVelocity",
controlVelocity.x,
controlVelocity.y,
)
shader.setFloatUniform("uCreep", creepProvider())
shader.setFloatUniform("uReleaseImpulse", releaseImpulseProvider())
shader.setFloatUniform("uLightSweep", lightSweepProvider())
shader.setFloatUniform("uMaterialDensity", materialDensity.value)
shader.setFloatUniform("uGloss", materialGloss.value)
drawRect(brush = shaderBrush)
}
}
@Composable
private fun FallbackGummyArtwork(
color: State<Color>,
pressProvider: () -> Float,
modifier: Modifier = Modifier,
) {
Canvas(modifier = modifier) {
val pigment = color.value
drawFallbackBackground()
val squash = pressProvider()
val unit = size.minDimension
val center = Offset(size.width * 0.5f, size.height * 0.5f)
val scaleX = 1f + squash * 0.1f
val scaleY = 1f - squash * 0.1f
val bodySize = Size(unit * 0.43f * scaleX, unit * 0.58f * scaleY)
val bodyTopLeft = Offset(center.x - bodySize.width / 2f, center.y - bodySize.height * 0.38f)
drawOval(
color = Color.Black.copy(alpha = 0.34f),
topLeft = Offset(center.x - unit * 0.27f, center.y + unit * 0.37f),
size = Size(unit * 0.54f, unit * 0.09f),
)
drawOval(
brush = Brush.linearGradient(
colors = listOf(pigment.copy(alpha = 0.96f), pigment.copy(alpha = 0.58f)),
start = bodyTopLeft,
end = Offset(bodyTopLeft.x + bodySize.width, bodyTopLeft.y + bodySize.height),
),
topLeft = bodyTopLeft,
size = bodySize,
)
drawCircle(pigment.copy(alpha = 0.9f), unit * 0.2f * scaleX, Offset(center.x, center.y - unit * 0.22f))
drawCircle(Color.White.copy(alpha = 0.32f), unit * 0.052f, Offset(center.x - unit * 0.08f, center.y - unit * 0.29f))
}
}
private fun DrawScope.drawFallbackBackground() {
drawRect(
brush = Brush.verticalGradient(
listOf(Color(0xFF29152A), Color(0xFF120A16), Color(0xFF1C0D17)),
),
)
drawCircle(
brush = Brush.radialGradient(
listOf(Color(0x33FF806F), Color.Transparent),
center = Offset(size.width * 0.5f, size.height * 0.44f),
radius = size.minDimension * 0.62f,
),
radius = size.minDimension * 0.62f,
center = Offset(size.width * 0.5f, size.height * 0.44f),
)
}
private const val GUMMY_SHADER = """
uniform float2 uResolution;
uniform float uTime;
uniform float uPress;
uniform float2 uTouch;
uniform float2 uDrag;
uniform float uReleaseTime;
uniform float3 uColor;
uniform float uObjectMode;
uniform float uTransparent;
uniform float2 uObjectCenter;
uniform float uObjectRadius;
uniform float uObjectRotation;
uniform float2 uImpactNormal;
uniform float uGelDeform;
uniform float uIsometric;
uniform float2 uIsoControl;
uniform float2 uIsoVelocity;
uniform float uCreep;
uniform float uReleaseImpulse;
uniform float uLightSweep;
uniform float uMaterialDensity;
uniform float uGloss;
float smin(float a, float b, float k) {
float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
return mix(b, a, h) - k * h * (1.0 - h);
}
float sdCircle(float2 p, float r) {
return length(p) - r;
}
float sdEllipse(float2 p, float2 radii) {
return (length(p / radii) - 1.0) * min(radii.x, radii.y);
}
float sdRoundBox(float2 p, float2 halfSize, float radius) {
float2 q = abs(p) - halfSize + radius;
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - radius;
}
float bear(float2 p, float t) {
float elapsed = max(0.0, uTime - uReleaseTime);
float recoil = exp(-elapsed * 2.45) * sin(elapsed * 19.0) * (1.0 - clamp(uPress, 0.0, 1.0));
float idle = sin(t * 1.7) * 0.004;
p.x += sin(p.y * 10.0 + t * 1.9) * (0.003 + 0.007 * clamp(uPress, 0.0, 1.0));
p.x += sin(p.y * 13.0 + t * 2.6) * recoil * 0.030;
p.y += idle + sin(p.x * 11.0 - t * 1.4) * 0.0025;
p.y += sin(p.x * 12.0 - t * 2.1) * recoil * 0.020;
// Compact molded-bear proportions: broad head, low shoulders, downward
// arms and heavy feet. This keeps the silhouette readable at every angle.
float head = sdRoundBox(p - float2(0.0, -0.195), float2(0.218, 0.190), 0.095);
float leftEar = sdCircle(p - float2(-0.160, -0.360), 0.090);
float rightEar = sdCircle(p - float2(0.160, -0.360), 0.090);
float body = sdEllipse(p - float2(0.0, 0.125), float2(0.222, 0.300));
float leftArm = sdEllipse(p - float2(-0.205, 0.080), float2(0.105, 0.165));
float rightArm = sdEllipse(p - float2(0.205, 0.080), float2(0.105, 0.165));
float leftLeg = sdEllipse(p - float2(-0.125, 0.355), float2(0.118, 0.180));
float rightLeg = sdEllipse(p - float2(0.125, 0.355), float2(0.118, 0.180));
float d = smin(head, body, 0.060);
d = smin(d, leftEar, 0.026);
d = smin(d, rightEar, 0.026);
d = smin(d, leftArm, 0.052);
d = smin(d, rightArm, 0.052);
d = smin(d, leftLeg, 0.042);
d = smin(d, rightLeg, 0.042);
return d;
}
float ellipsoidHeight(float2 p, float2 center, float2 radii, float height) {
float2 q = (p - center) / radii;
return sqrt(max(0.0, 1.0 - dot(q, q))) * height;
}
float roundedHeadHeight(float2 p) {
float2 q = abs((p - float2(0.0, -0.195)) / float2(0.220, 0.192));
float superEllipse = pow(q.x, 4.0) + pow(q.y, 4.0);
return sqrt(max(0.0, 1.0 - superEllipse)) * 0.168;
}
float smoothHeightUnion(float a, float b, float softness) {
float h = clamp(0.5 + 0.5 * (a - b) / softness, 0.0, 1.0);
float merged = mix(b, a, h) + softness * h * (1.0 - h);
return merged * step(0.00001, max(a, b));
}
float bearSurfaceHeight(float2 p) {
float head = roundedHeadHeight(p);
float leftEar = ellipsoidHeight(p, float2(-0.160, -0.360), float2(0.091, 0.091), 0.118);
float rightEar = ellipsoidHeight(p, float2(0.160, -0.360), float2(0.091, 0.091), 0.118);
float body = ellipsoidHeight(p, float2(0.0, 0.125), float2(0.224, 0.302), 0.188);
float leftArm = ellipsoidHeight(p, float2(-0.205, 0.080), float2(0.106, 0.166), 0.142);
float rightArm = ellipsoidHeight(p, float2(0.205, 0.080), float2(0.106, 0.166), 0.142);
float leftLeg = ellipsoidHeight(p, float2(-0.125, 0.355), float2(0.120, 0.182), 0.158);
float rightLeg = ellipsoidHeight(p, float2(0.125, 0.355), float2(0.120, 0.182), 0.158);
float ears = smoothHeightUnion(leftEar, rightEar, 0.016);
float surface = smoothHeightUnion(head, ears, 0.030);
surface = smoothHeightUnion(surface, body, 0.056);
surface = smoothHeightUnion(surface, leftArm, 0.036);
surface = smoothHeightUnion(surface, rightArm, 0.036);
surface = smoothHeightUnion(surface, leftLeg, 0.038);
surface = smoothHeightUnion(surface, rightLeg, 0.038);
// The snout is a real forward-facing dome rather than a painted oval.
float2 muzzleQ = (p - float2(0.0, -0.125)) / float2(0.140, 0.094);
float muzzleDome = exp(-dot(muzzleQ, muzzleQ) * 1.55) * 0.054;
float2 bellyQ = (p - float2(-0.018, 0.145)) / float2(0.190, 0.260);
float bellyDome = exp(-dot(bellyQ, bellyQ) * 1.8) * 0.018;
return surface + muzzleDome + bellyDome;
}
float interactiveSurfaceHeight(
float2 sampleP,
float2 touchP,
float pressAmount,
float releaseEnergy
) {
float baseHeight = bearSurfaceHeight(sampleP);
float creep = clamp(uCreep, 0.0, 1.0);
float2 touchDelta = sampleP - touchP;
float touchDistance = length(touchDelta);
float dentRadius = mix(0.0036, 0.0056, creep);
float localDent = exp(-dot(touchDelta, touchDelta) / dentRadius)
* pressAmount
* mix(0.045, 0.057, creep);
float rimRadius = mix(0.066, 0.082, creep);
float displacedRim = exp(-pow((touchDistance - rimRadius) / 0.027, 2.0))
* pressAmount
* mix(0.008, 0.012, creep);
float releaseAge = max(0.0, uTime - uReleaseTime);
float rippleEnvelope = exp(-releaseAge * 3.8) * (1.0 - pressAmount);
float releasePulse = max(releaseEnergy, clamp(uReleaseImpulse, 0.0, 1.0));
float ripple = sin(touchDistance * 72.0 - releaseAge * 20.0)
* exp(-touchDistance * 5.5)
* rippleEnvelope
* (0.007 + releasePulse * 0.022);
ripple += sin(touchDistance * 43.0 - releaseAge * 13.0 + 1.2)
* exp(-touchDistance * 4.2)
* rippleEnvelope
* releasePulse
* 0.006;
return max(0.0, baseHeight - localDent + displacedRim + ripple);
}
float2 applyIsometricProjection(float2 p, float amount, float2 control) {
float yaw = clamp(control.x, -1.0, 1.0);
float elevation = clamp(control.y, -1.0, 1.0);
float topView = clamp(0.50 - elevation * 0.50, 0.0, 1.0);
float angle = (-0.070 - yaw * 0.285) * amount;
float c = cos(angle);
float s = sin(angle);
float2 rotated = float2(p.x * c - p.y * s, p.x * s + p.y * c);
float elevationScale = mix(0.98, 0.82, topView);
float yawScale = 0.98 - abs(yaw) * 0.060;
rotated.x /= mix(1.0, yawScale, amount);
rotated.y /= mix(1.0, elevationScale, amount);
rotated.y += rotated.x * yaw * 0.055 * amount;
return rotated;
}
// Screen-space projection of the distance between the candy's front and back
// faces. Unlike the projection above, this creates an actual swept solid: the
// rear face and the connecting gel wall occupy their own pixels.
float2 isometricExtrusionOffset(float amount, float2 control) {
float yaw = clamp(control.x, -1.0, 1.0);
float elevation = clamp(control.y, -1.0, 1.0);
float topView = clamp(0.50 - elevation * 0.50, 0.0, 1.0);
float visibleThickness = mix(0.060, 0.142, topView);
return float2(yaw * 0.105, visibleThickness) * amount;
}
float3 backdrop(float2 uv) {
float3 top = float3(0.145, 0.070, 0.155);
float3 bottom = float3(0.042, 0.020, 0.052);
float3 color = mix(top, bottom, smoothstep(0.05, 0.88, uv.y));
float2 glowP = (uv - float2(0.50, 0.44)) * float2(1.0, 0.72);
float glow = exp(-dot(glowP, glowP) * 5.0);
color += float3(0.14, 0.045, 0.07) * glow;
float vignette = smoothstep(0.84, 0.22, length((uv - 0.5) * float2(0.86, 1.0)));
color *= 0.68 + 0.32 * vignette;
return color;
}
float3 environmentReflection(float3 direction) {
float sky = smoothstep(-0.55, 0.75, direction.y);
float3 environment = mix(
float3(0.10, 0.025, 0.055),
float3(0.78, 0.86, 0.98),
sky
);
float studioStrip = exp(-pow((direction.x + direction.y * 0.24 + 0.34) / 0.16, 2.0));
float warmBounce = exp(-pow((direction.x - 0.72) / 0.24, 2.0))
* smoothstep(-0.35, 0.45, direction.y);
environment += float3(1.0, 0.94, 0.88) * studioStrip * 0.48;
environment += float3(0.45, 0.12, 0.08) * warmBounce * 0.20;
return environment;
}
float bubble(float2 p, float2 center, float radius) {
float d = length(p - center);
return smoothstep(radius, radius * 0.48, d) * (0.35 + 0.65 * smoothstep(radius * 0.55, radius * 0.85, d));
}
half4 main(float2 fragCoord) {
float2 uv = fragCoord / uResolution;
float minSide = min(uResolution.x, uResolution.y);
float objectMode = step(0.5, uObjectMode);
float transparentMode = step(0.5, uTransparent);
float isometricMode = step(0.5, uIsometric);
float2 isoControl = clamp(uIsoControl, float2(-1.0), float2(1.0));
float2 isoVelocity = clamp(uIsoVelocity, float2(-4.0), float2(4.0));
float2 screenP = (fragCoord - uResolution * float2(0.5, 0.50)) / minSide;
screenP = applyIsometricProjection(screenP, isometricMode, isoControl);
float2 objectDelta = fragCoord - uObjectCenter;
float rotC = cos(-uObjectRotation);
float rotS = sin(-uObjectRotation);
float2 objectP = float2(
objectDelta.x * rotC - objectDelta.y * rotS,
objectDelta.x * rotS + objectDelta.y * rotC
) / max(uObjectRadius, 1.0) * 0.52;
float2 rawP = mix(screenP, objectP, objectMode);
float3 background = backdrop(uv);
float pressAmount = clamp(uPress, 0.0, 1.0);
float sweepActive = step(0.0, uLightSweep) * step(uLightSweep, 1.0);
float sweepCenter = mix(-0.52, 0.52, clamp(uLightSweep, 0.0, 1.0));
float backgroundSweep = exp(-pow((rawP.x + rawP.y * 0.12 - sweepCenter) / 0.085, 2.0));
background += mix(uColor, float3(1.0), 0.72) * backgroundSweep * sweepActive * 0.035;
float shadowWidth = 0.31 + 0.055 * pressAmount;
float shadow = exp(-pow(rawP.x / shadowWidth, 2.0) - pow((rawP.y - 0.515) / 0.050, 2.0));
background *= 1.0 - shadow * 0.46;
float caustic = exp(-pow(rawP.x / 0.24, 2.0) - pow((rawP.y - 0.495) / 0.024, 2.0));
background += uColor * caustic * 0.105;
float2 screenTouchP = (uTouch - uResolution * float2(0.5, 0.50)) / minSide;
screenTouchP = applyIsometricProjection(screenTouchP, isometricMode, isoControl);
float2 objectTouchDelta = uTouch - uObjectCenter;
float2 objectTouchP = float2(
objectTouchDelta.x * rotC - objectTouchDelta.y * rotS,
objectTouchDelta.x * rotS + objectTouchDelta.y * rotC
) / max(uObjectRadius, 1.0) * 0.52;
float2 touchP = mix(screenTouchP, objectTouchP, objectMode);
float2 objectDragP = float2(
uDrag.x * rotC - uDrag.y * rotS,
uDrag.x * rotS + uDrag.y * rotC
) / max(uObjectRadius, 1.0) * 0.52;
float2 screenDragP = applyIsometricProjection(uDrag / minSide, isometricMode, isoControl);
float2 dragP = mix(screenDragP, objectDragP, objectMode);
float dragLength = max(length(dragP), 0.0001);
dragP *= min(1.0, 0.22 / dragLength);
float2 p = rawP;
float horizontalScale = 1.0 + (0.072 + uCreep * 0.018) * uPress;
float verticalScale = 1.0 - (0.082 + uCreep * 0.014) * uPress;
float2 legacyP = float2(p.x / horizontalScale, (p.y - 0.47) / verticalScale + 0.47);
float2 legacyTouchP = float2(
touchP.x / horizontalScale,
(touchP.y - 0.47) / verticalScale + 0.47
);
float2 objectImpactNormal = float2(
uImpactNormal.x * rotC - uImpactNormal.y * rotS,
uImpactNormal.x * rotS + uImpactNormal.y * rotC
);
objectImpactNormal /= max(length(objectImpactNormal), 0.0001);
float2 deformAxis = mix(float2(0.0, -1.0), objectImpactNormal, objectMode);
float2 deformTangent = float2(-deformAxis.y, deformAxis.x);
float gelDeform = mix(pressAmount, clamp(uGelDeform, -0.16, 0.58), objectMode);
float axisScale = max(0.78, 1.0 - gelDeform * 0.28);
float tangentScale = max(0.86, 1.0 + gelDeform * 0.17);
float2 gelCenter = float2(0.0, 0.08);
float2 gelDelta = p - gelCenter;
float2 orientedP = gelCenter
+ deformAxis * dot(gelDelta, deformAxis) / axisScale
+ deformTangent * dot(gelDelta, deformTangent) / tangentScale;
float2 gelTouchDelta = touchP - gelCenter;
float2 orientedTouchP = gelCenter
+ deformAxis * dot(gelTouchDelta, deformAxis) / axisScale
+ deformTangent * dot(gelTouchDelta, deformTangent) / tangentScale;
p = mix(legacyP, orientedP, objectMode);
touchP = mix(legacyTouchP, orientedTouchP, objectMode);
float2 pressedP = p;
float initialTouchDistance = length(pressedP - touchP);
float materialPull = exp(-initialTouchDistance * initialTouchDistance / 0.036) * pressAmount;
p = pressedP - dragP * materialPull * 0.92;
// No offset silhouette shadow: only the compact ground contact shadow above
// remains, so the gummy has no hazy border behind it.
float touchDistance = length(p - touchP);
float contact = exp(-touchDistance * touchDistance / 0.0038) * pressAmount;
float pressureRing = exp(-pow((touchDistance - 0.073) / 0.034, 2.0)) * pressAmount;
float d = bear(p, uTime);
d -= pressureRing * 0.010;
// Build a real extruded volume for Grape mode. The minimum distance over
// several depth slices is the swept solid connecting front and rear faces.
// A static loop keeps this valid and predictable for RuntimeShader/AGSL.
float2 extrusionOffset = isometricExtrusionOffset(isometricMode, isoControl);
float extrudedD = d;
float extrusionLayer = 0.0;
if (isometricMode > 0.5) {
for (int layerIndex = 1; layerIndex <= 8; ++layerIndex) {
float layer = float(layerIndex) / 8.0;
float layerD = bear(p - extrusionOffset * layer, uTime);
if (layerD < extrudedD) {
extrudedD = layerD;
extrusionLayer = layer;
}
}
}
// A wider derivative removes hard normal seams where the soft body parts merge.
float eps = 0.0050;
float2 rawGrad = float2(
bear(p + float2(eps, 0.0), uTime) - bear(p - float2(eps, 0.0), uTime),
bear(p + float2(0.0, eps), uTime) - bear(p - float2(0.0, eps), uTime)
);
float2 grad = rawGrad / max(length(rawGrad), 0.0001);
float depth = smoothstep(0.0, 0.165, -d);
float releaseEnergy = max(
clamp(length(dragP) * 4.5, 0.0, 1.0),
clamp(uReleaseImpulse, 0.0, 1.0)
);
float volumeHeight = interactiveSurfaceHeight(p, touchP, pressAmount, releaseEnergy);
float height = clamp(volumeHeight / 0.195, 0.0, 1.0);
float edgeSlope = pow(clamp(1.0 - height, 0.0, 1.0), 0.64);
float side = edgeSlope * 0.94;
float2 contactRadial = (p - touchP) / max(touchDistance, 0.001);
float heightEps = 0.0035;
float heightLeft = interactiveSurfaceHeight(
p - float2(heightEps, 0.0), touchP, pressAmount, releaseEnergy
);
float heightRight = interactiveSurfaceHeight(
p + float2(heightEps, 0.0), touchP, pressAmount, releaseEnergy
);
float heightTop = interactiveSurfaceHeight(
p - float2(0.0, heightEps), touchP, pressAmount, releaseEnergy
);
float heightBottom = interactiveSurfaceHeight(
p + float2(0.0, heightEps), touchP, pressAmount, releaseEnergy
);
float3 volumeNormal = normalize(float3(
heightLeft - heightRight,
heightTop - heightBottom,
heightEps * 1.82
));
float3 edgeNormal = normalize(float3(
grad * side,
max(0.08, 0.24 + height * 0.96)
));
float volumeBlend = smoothstep(0.006, 0.055, volumeHeight) * 0.90;
float3 normal = normalize(mix(edgeNormal, volumeNormal, volumeBlend));
normal = normalize(float3(
normal.xy - contactRadial * pressureRing * 0.52,
max(0.07, normal.z - contact * 0.42)
));
float microEnvelope = smoothstep(0.10, 0.78, height) * (1.0 - edgeSlope * 0.55);
float2 microNormal = float2(
sin(p.x * 173.0 + p.y * 91.0),
cos(p.y * 157.0 - p.x * 83.0)
) * 0.009 * microEnvelope;
normal = normalize(float3(normal.xy + microNormal, normal.z));
float3 controlledViewDir = normalize(float3(
isoControl.x * 0.58 + isoVelocity.x * 0.018,
-0.20 + isoControl.y * 0.52 + isoVelocity.y * 0.012,
0.86
));
float3 viewDir = normalize(mix(
float3(0.0, 0.0, 1.0),
controlledViewDir,
isometricMode
));
float3 controlledLightDir = normalize(float3(
-0.48 + isoControl.x * 0.22 - isoVelocity.x * 0.035,
-0.62 + isoControl.y * 0.20 - isoVelocity.y * 0.022,
0.90
));
float3 controlledFillLightDir = normalize(float3(
0.72 - isoControl.x * 0.15,
0.18 - isoControl.y * 0.12,
0.68
));
float3 lightDir = normalize(mix(float3(-0.48, -0.62, 0.90), controlledLightDir, isometricMode));
float3 fillLightDir = normalize(mix(float3(0.72, 0.18, 0.68), controlledFillLightDir, isometricMode));
float3 halfDir = normalize(lightDir + viewDir);
float3 fillHalfDir = normalize(fillLightDir + viewDir);
float keyDiffuse = max(0.0, dot(normal, lightDir));
float fillDiffuse = max(0.0, dot(normal, fillLightDir));
float diffuse = 0.18 + keyDiffuse * 0.64 + fillDiffuse * 0.20;
float specRegion = 0.72 + 0.28 * (1.0 - smoothstep(0.16, 0.31, abs(p.x)));
float specular = pow(max(0.0, dot(normal, halfDir)), 48.0) * specRegion;
float tightSpecular = pow(max(0.0, dot(normal, halfDir)), 168.0) * specRegion;
float fillSpecular = pow(max(0.0, dot(normal, fillHalfDir)), 76.0);
float nDotV = clamp(dot(normal, viewDir), 0.0, 1.0);
float fresnel = 0.035 + 0.965 * pow(1.0 - nDotV, 5.0);
float rim = pow(clamp(1.0 - nDotV, 0.0, 1.0), 1.75);
float edgeTransmission = pow(clamp(1.0 - depth, 0.0, 1.0), 1.25);
float lowerSide = smoothstep(-0.02, 0.46, p.y) * edgeSlope;
float2 refractedUv = uv + normal.xy * (0.010 + 0.026 * height);
float3 refracted = backdrop(refractedUv);
float opticalDepth = (0.25 + height * 1.95) * max(0.65, uMaterialDensity);
float3 absorptionCoefficient = max(float3(0.16), float3(1.15) - uColor * 0.90);
float3 transmittance = exp(-absorptionCoefficient * opticalDepth);
float3 transmittedLight = refracted * transmittance * 0.96;
float3 saturatedPigment = mix(uColor, uColor * uColor, 0.28);
float3 internalScatter = saturatedPigment
* (float3(1.0) - transmittance)
* (0.62 + diffuse * 0.26);
float3 candy = transmittedLight + internalScatter;
candy *= 0.88 + diffuse * 0.20;
candy *= 1.0 - lowerSide * 0.18;
float backLight = exp(
-pow((p.x + 0.10) / 0.31, 2.0)
-pow((p.y + 0.10) / 0.43, 2.0)
);
float footCaustic = exp(
-pow(p.x / 0.19, 2.0)
-pow((p.y - 0.46) / 0.050, 2.0)
);
candy += mix(saturatedPigment, float3(1.0), 0.14)
* (float3(1.0) - transmittance)
* backLight
* (0.055 + keyDiffuse * 0.045);
candy += saturatedPigment * footCaustic * 0.10;
float3 reflectionDirection = reflect(-viewDir, normal);
float3 reflectedEnvironment = environmentReflection(reflectionDirection);
candy = mix(candy, reflectedEnvironment, fresnel * 0.38);
candy += saturatedPigment * edgeTransmission * (0.26 + 0.14 * keyDiffuse);
float gloss = clamp(uGloss, 0.72, 1.28);
candy += float3(1.0, 0.95, 0.90) * specular * 0.40 * gloss;
candy += float3(1.0) * tightSpecular * 0.34 * gloss;
candy += float3(0.68, 0.82, 1.0) * fillSpecular * 0.12 * gloss;
candy += mix(saturatedPigment, float3(1.0), 0.36) * rim * 0.22;
candy *= 1.0 - contact * 0.24;
candy += mix(uColor, float3(1.0), 0.58) * pressureRing * 0.16;
float subsurface = max(0.0, dot(-normal, lightDir)) * edgeTransmission;
candy += uColor * subsurface * 0.28;
float surfaceSweep = exp(-pow((p.x + p.y * 0.12 - sweepCenter) / 0.050, 2.0));
float sweepFacing = 0.30 + 0.70 * max(0.0, dot(normal, normalize(float3(-0.42, -0.50, 0.92))));
candy += mix(saturatedPigment, float3(1.0), 0.82)
* surfaceSweep
* sweepActive
* sweepFacing
* 0.42;
float inclusions = 0.0;
inclusions += bubble(p, float2(-0.078, 0.075), 0.017);
inclusions += bubble(p, float2(0.095, 0.182), 0.011);
inclusions += bubble(p, float2(-0.035, 0.292), 0.008);
inclusions += bubble(p, float2(0.105, -0.095), 0.007);
inclusions += bubble(p, float2(-0.155, 0.205), 0.006);
inclusions = clamp(inclusions, 0.0, 1.0);
candy = mix(candy, refracted * 1.18, inclusions * 0.16);
candy += inclusions * reflectedEnvironment * fresnel * 0.10;
// Shallow mold grooves keep the compact arms and feet readable without
// breaking the continuous transparent gel surface.
float leftArmProfile = length((p - float2(-0.205, 0.080)) / float2(0.105, 0.165));
float rightArmProfile = length((p - float2(0.205, 0.080)) / float2(0.105, 0.165));
float leftLegProfile = length((p - float2(-0.125, 0.355)) / float2(0.118, 0.180));
float rightLegProfile = length((p - float2(0.125, 0.355)) / float2(0.118, 0.180));
float armGroove = max(
exp(-pow((leftArmProfile - 0.80) / 0.075, 2.0)),
exp(-pow((rightArmProfile - 0.80) / 0.075, 2.0))
);
float legGroove = max(
exp(-pow((leftLegProfile - 0.82) / 0.072, 2.0)),
exp(-pow((rightLegProfile - 0.82) / 0.072, 2.0))
);
float limbGroove = max(armGroove, legGroove);
candy = mix(candy, uColor * 0.48, limbGroove * 0.032);
candy += mix(uColor, float3(1.0), 0.46) * limbGroove * rim * 0.030;
float2 faceP = pressedP - dragP * materialPull * 0.34;
float2 muzzleP = (faceP - float2(0.0, -0.125)) / float2(0.138, 0.092);
float muzzleDistance = length(muzzleP);
float muzzleFill = 1.0 - smoothstep(0.76, 1.08, muzzleDistance);
float muzzleRim = exp(-pow((muzzleDistance - 0.93) / 0.115, 2.0));
float leftMuzzle = 1.0 - smoothstep(
0.050,
0.082,
length((faceP - float2(-0.045, -0.112)) * float2(1.0, 1.18))
);
float rightMuzzle = 1.0 - smoothstep(
0.050,
0.082,
length((faceP - float2(0.045, -0.112)) * float2(1.0, 1.18))
);
float muzzleLobes = max(leftMuzzle, rightMuzzle);
candy *= 1.0 - muzzleRim * 0.040;
candy += mix(uColor, float3(1.0), 0.48) * muzzleFill * 0.030;
candy += mix(uColor, float3(1.0), 0.60) * muzzleRim * 0.070;
candy += float3(1.0, 0.91, 0.82) * muzzleLobes * tightSpecular * 0.12;
float innerEarLeft = 1.0 - smoothstep(0.050, 0.073, length(faceP - float2(-0.160, -0.360)));
float innerEarRight = 1.0 - smoothstep(0.050, 0.073, length(faceP - float2(0.160, -0.360)));
float innerEars = max(innerEarLeft, innerEarRight);
candy = mix(candy, uColor * 0.48, innerEars * 0.11);
candy += mix(uColor, float3(1.0), 0.56) * innerEars * rim * 0.08;
float eyeLeft = 1.0 - smoothstep(0.011, 0.020, length(faceP - float2(-0.067, -0.225)));
float eyeRight = 1.0 - smoothstep(0.011, 0.020, length(faceP - float2(0.067, -0.225)));
float nose = 1.0 - smoothstep(0.014, 0.025, length((faceP - float2(0.0, -0.158)) * float2(1.0, 1.22)));
float face = max(max(eyeLeft, eyeRight), nose);
float3 embeddedFace = candy * 0.30 + uColor * 0.055;
candy = mix(candy, embeddedFace, face * 0.50 * (1.0 - contact * 0.35));
float eyeCatchLeft = 1.0 - smoothstep(0.0025, 0.0055, length(faceP - float2(-0.072, -0.231)));
float eyeCatchRight = 1.0 - smoothstep(0.0025, 0.0055, length(faceP - float2(0.062, -0.231)));
candy += float3(1.0) * (eyeCatchLeft + eyeCatchRight) * 0.34;
float moldLine = 1.0 - smoothstep(0.002, 0.006, abs(d + 0.016));
candy += moldLine * mix(uColor, float3(1.0), 0.18) * 0.045;
float aa = 1.6 / minSide;
float frontMask = smoothstep(aa, -aa, d);
float solidMask = smoothstep(aa, -aa, extrudedD);
float sideMask = clamp(solidMask - frontMask, 0.0, 1.0) * isometricMode;
// The connecting wall has its own normal, optical depth and reflections.
// It is intentionally denser than the face because light travels farther
// through the candy when the camera sees its thickness edge-on.
float extrusionLength = max(length(extrusionOffset), 0.0001);
float2 extrusionDirection = extrusionOffset / extrusionLength;
float2 sideGradient = normalize(grad - extrusionDirection * 0.48);
float3 sideNormal = normalize(float3(sideGradient, 0.16 + extrusionLayer * 0.05));
float sideDiffuse = 0.16
+ max(0.0, dot(sideNormal, lightDir)) * 0.60
+ max(0.0, dot(sideNormal, fillLightDir)) * 0.18;
float sideNdotV = clamp(dot(sideNormal, viewDir), 0.0, 1.0);
float sideFresnel = 0.045 + 0.955 * pow(1.0 - sideNdotV, 5.0);
float sideOpticalDepth = (1.45 + extrusionLayer * 1.70) * max(0.65, uMaterialDensity);
float3 sideTransmittance = exp(-absorptionCoefficient * sideOpticalDepth);
float3 sideRefraction = backdrop(
uv + normal.xy * 0.016 + extrusionDirection * extrusionLayer * 0.012
);
float3 sideCandy = sideRefraction * sideTransmittance * 0.72
+ saturatedPigment
* (float3(1.0) - sideTransmittance)
* (0.54 + sideDiffuse * 0.28);
float3 sideReflection = environmentReflection(reflect(-viewDir, sideNormal));
sideCandy = mix(sideCandy, sideReflection, sideFresnel * 0.44);
float sideSpecular = pow(max(0.0, dot(sideNormal, halfDir)), 92.0);
sideCandy += float3(1.0, 0.95, 0.92) * sideSpecular * 0.40 * gloss;
sideCandy += saturatedPigment * (0.10 + extrusionLayer * 0.09);
sideCandy += mix(saturatedPigment, float3(1.0), 0.72)
* surfaceSweep
* sweepActive
* (0.18 + extrusionLayer * 0.18);
float frontDepthSeam = exp(-pow(d / 0.0065, 2.0)) * sideMask;
sideCandy += mix(saturatedPigment, float3(1.0), 0.34) * frontDepthSeam * 0.30;
float3 finalColor = background;
finalColor = mix(finalColor, sideCandy, sideMask);
finalColor = mix(finalColor, candy, frontMask);
float objectAlpha = max(frontMask, sideMask);
float alpha = mix(1.0, clamp(objectAlpha, 0.0, 1.0), transparentMode);
return half4(half3(max(finalColor, 0.0)), half(alpha));
}
"""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment