Created
July 12, 2026 10:51
-
-
Save ardakazanci/d5a86d50cdade83bd93239c7da7cde1f to your computer and use it in GitHub Desktop.
Jetpack Compose Gummy Effect
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
| 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