fix: fix gooey animation

This commit is contained in:
k1ngsterr1
2026-02-09 11:33:23 +05:00
parent 8dfcf1c410
commit 1139bd6be6
2 changed files with 171 additions and 102 deletions

View File

@@ -13,6 +13,20 @@ import kotlin.math.sqrt
*/ */
data class Point(var x: Float = 0f, var y: Float = 0f) data class Point(var x: Float = 0f, var y: Float = 0f)
/**
* Reusable scratch points to avoid per-frame allocations during scroll.
*/
class MetaballWorkspace {
val p1 = Point()
val p2 = Point()
val p3 = Point()
val p4 = Point()
val h1 = Point()
val h2 = Point()
val h3 = Point()
val h4 = Point()
}
/** /**
* Creates a metaball path between two circles - exact port from Telegram's ProfileMetaballView.java * Creates a metaball path between two circles - exact port from Telegram's ProfileMetaballView.java
* *
@@ -33,28 +47,18 @@ fun createMetaballPath(
radius1: Float, radius1: Float,
radius2: Float, radius2: Float,
v: Float, v: Float,
path: Path path: Path,
workspace: MetaballWorkspace = MetaballWorkspace()
): Boolean { ): Boolean {
val HALF_PI = (Math.PI / 2).toFloat() val HALF_PI = (Math.PI / 2).toFloat()
val HANDLE_SIZE = 20.0f val HANDLE_SIZE = 3.6f
// Helper points for path calculation
val p1 = Point()
val p2 = Point()
val p3 = Point()
val p4 = Point()
val h1 = Point()
val h2 = Point()
val h3 = Point()
val h4 = Point()
val a1 = Point()
val a2 = Point()
// Distance between centers // Distance between centers
val d = dist(c1, c2) val d = dist(c1, c2)
val maxDist = radius1 + radius2 * 2.25f val maxDist = radius1 + radius2 * 2.32f
var u1 = 0f var u1 = 0f
var u2 = 0f var u2 = 0f
val connector = v.coerceIn(0f, 1f)
// Check if circles can be connected // Check if circles can be connected
if (radius1 == 0f || radius2 == 0f || d > maxDist || d <= abs(radius1 - radius2)) { if (radius1 == 0f || radius2 == 0f || d > maxDist || d <= abs(radius1 - radius2)) {
@@ -63,18 +67,29 @@ fun createMetaballPath(
// Calculate overlap angles // Calculate overlap angles
if (d < radius1 + radius2) { if (d < radius1 + radius2) {
u1 = acos((radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d)) val acosArg1 = ((radius1 * radius1 + d * d - radius2 * radius2) / (2 * radius1 * d)).coerceIn(-1f, 1f)
u2 = acos((radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d)) val acosArg2 = ((radius2 * radius2 + d * d - radius1 * radius1) / (2 * radius2 * d)).coerceIn(-1f, 1f)
u1 = acos(acosArg1)
u2 = acos(acosArg2)
} }
val angleBetweenCenters = angle(c2, c1) val angleBetweenCenters = angle(c2, c1)
val maxSpread = acos((radius1 - radius2) / d) val maxSpread = acos(((radius1 - radius2) / d).coerceIn(-1f, 1f))
// Calculate connection angles // Calculate connection angles
val angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * v val angle1 = angleBetweenCenters + u1 + (maxSpread - u1) * connector
val angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * v val angle2 = angleBetweenCenters - u1 - (maxSpread - u1) * connector
val angle3 = angleBetweenCenters + Math.PI.toFloat() - u2 - (Math.PI.toFloat() - u2 - maxSpread) * v val angle3 = angleBetweenCenters + Math.PI.toFloat() - u2 - (Math.PI.toFloat() - u2 - maxSpread) * connector
val angle4 = angleBetweenCenters - Math.PI.toFloat() + u2 + (Math.PI.toFloat() - u2 - maxSpread) * v val angle4 = angleBetweenCenters - Math.PI.toFloat() + u2 + (Math.PI.toFloat() - u2 - maxSpread) * connector
val p1 = workspace.p1
val p2 = workspace.p2
val p3 = workspace.p3
val p4 = workspace.p4
val h1 = workspace.h1
val h2 = workspace.h2
val h3 = workspace.h3
val h4 = workspace.h4
// Get points on circle edges // Get points on circle edges
getVector(c1, angle1, radius1, p1) getVector(c1, angle1, radius1, p1)
@@ -84,7 +99,7 @@ fun createMetaballPath(
// Calculate handle lengths for Bezier curves // Calculate handle lengths for Bezier curves
val totalRadius = radius1 + radius2 val totalRadius = radius1 + radius2
val d2Base = minOf(v * HANDLE_SIZE, dist(p1, p3) / totalRadius) val d2Base = minOf(connector * HANDLE_SIZE, dist(p1, p3) / totalRadius)
val d2 = d2Base * minOf(1f, d * 2 / totalRadius) val d2 = d2Base * minOf(1f, d * 2 / totalRadius)
val r1 = radius1 * d2 val r1 = radius1 * d2
@@ -100,13 +115,9 @@ fun createMetaballPath(
path.rewind() path.rewind()
path.moveTo(p1.x, p1.y) path.moveTo(p1.x, p1.y)
path.cubicTo(h1.x, h1.y, h3.x, h3.y, p3.x, p3.y) path.cubicTo(h1.x, h1.y, h3.x, h3.y, p3.x, p3.y)
path.lineTo(p4.x, p4.y)
// Smooth curve around bottom circle
getVector(p3, angle3 + HALF_PI, r2 * 0.55f, a1)
getVector(p4, angle4 - HALF_PI, r2 * 0.55f, a2)
path.cubicTo(a1.x, a1.y, a2.x, a2.y, p4.x, p4.y)
path.cubicTo(h4.x, h4.y, h2.x, h2.y, p2.x, p2.y) path.cubicTo(h4.x, h4.y, h2.x, h2.y, p2.x, p2.y)
path.lineTo(p1.x, p1.y)
path.close() path.close()
return true return true

View File

@@ -108,8 +108,8 @@ object ProfileMetaballConstants {
const val MERGE_START_PROGRESS = 0.5f const val MERGE_START_PROGRESS = 0.5f
const val MERGE_COMPLETE_PROGRESS = 0.99f // Let opacity handle fade, don't cut off early const val MERGE_COMPLETE_PROGRESS = 0.99f // Let opacity handle fade, don't cut off early
// Blur settings for gooey effect (like Telegram's intensity = 15f) // Slightly stronger blur because our avatar base size is larger than Telegram's 96dp.
const val BLUR_RADIUS = 15f const val BLUR_RADIUS = 18f
// Blur range for avatar (like Telegram: 2 + (1-fraction) * 20) // Blur range for avatar (like Telegram: 2 + (1-fraction) * 20)
const val BLUR_MIN = 2f const val BLUR_MIN = 2f
@@ -218,7 +218,8 @@ private fun computeAvatarState(
collapseProgress > 0f -> { collapseProgress > 0f -> {
// Telegram: avatarScale = lerp(intoSize, 96, diff) / 100f // Telegram: avatarScale = lerp(intoSize, 96, diff) / 100f
// vr = baseWidth * avatarScale * 0.5 // vr = baseWidth * avatarScale * 0.5
val intoSize = notchRadiusPx * 2f // Target size = notch diameter // Keep a minimum collapsed size so tiny punch-hole cameras don't make the blob too small.
val intoSize = max(avatarSizeMinPx, notchRadiusPx * 2f)
val normalSize = avatarSizeExpandedPx val normalSize = avatarSizeExpandedPx
val avatarWidth = lerpFloat(intoSize, normalSize, diff) val avatarWidth = lerpFloat(intoSize, normalSize, diff)
avatarWidth / 2f // radius = half of width avatarWidth / 2f // radius = half of width
@@ -343,6 +344,12 @@ private fun lerpFloat(start: Float, stop: Float, fraction: Float): Float {
return start + (stop - start) * fraction return start + (stop - start) * fraction
} }
private fun easeOutCubic(value: Float): Float {
val t = value.coerceIn(0f, 1f)
val inv = 1f - t
return 1f - inv * inv * inv
}
/** /**
* Profile Metaball Effect with real Bezier path - like Telegram! * Profile Metaball Effect with real Bezier path - like Telegram!
* *
@@ -370,9 +377,6 @@ fun ProfileMetaballOverlay(
val screenWidth = configuration.screenWidthDp.dp val screenWidth = configuration.screenWidthDp.dp
val density = LocalDensity.current val density = LocalDensity.current
// Debug: log screen dimensions once
val TAG = "ProfileMetaball"
// Convert to pixels for path calculations // Convert to pixels for path calculations
val screenWidthPx = with(density) { screenWidth.toPx() } val screenWidthPx = with(density) { screenWidth.toPx() }
val statusBarHeightPx = with(density) { statusBarHeight.toPx() } val statusBarHeightPx = with(density) { statusBarHeight.toPx() }
@@ -386,10 +390,13 @@ fun ProfileMetaballOverlay(
NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view) NotchInfoUtils.getInfo(context) ?: NotchInfoUtils.getInfoFromCutout(view)
} }
// Debug log notch info once // Only log in explicit debug mode to keep production scroll clean.
LaunchedEffect(notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) { val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
Log.d(TAG, "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}, raw=${notchInfo?.rawPath}") LaunchedEffect(debugLogsEnabled, notchInfo, screenWidthPx, statusBarHeightPx, headerHeightPx) {
Log.d(TAG, "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px") if (debugLogsEnabled) {
Log.d("ProfileMetaball", "NotchInfo: gravity=${notchInfo?.gravity}, isCircle=${notchInfo?.isLikelyCircle}, bounds=${notchInfo?.bounds}, raw=${notchInfo?.rawPath}")
Log.d("ProfileMetaball", "Screen: width=${screenWidthPx}px, statusBar=${statusBarHeightPx}px, header=${headerHeightPx}px")
}
} }
// Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView) // Calculate notch radius based on real device notch (like Telegram's ProfileGooeyView)
@@ -431,12 +438,13 @@ fun ProfileMetaballOverlay(
} }
} }
// Telegram thresholds in pixels // Scale Telegram thresholds to our bigger base avatar size (120dp vs 96dp).
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } val thresholdScale = (avatarSizeExpandedPx / with(density) { 96.dp.toPx() }).coerceIn(1f, 1.35f)
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } * thresholdScale
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } * thresholdScale
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } * thresholdScale
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } * thresholdScale
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } * thresholdScale
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember // 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
// derivedStateOf автоматически отслеживает их как зависимости внутри лямбды // derivedStateOf автоматически отслеживает их как зависимости внутри лямбды
@@ -469,9 +477,16 @@ fun ProfileMetaballOverlay(
val metaballPath = remember { Path() } val metaballPath = remember { Path() }
val c1 = remember { Point() } // Notch center val c1 = remember { Point() } // Notch center
val c2 = remember { Point() } // Avatar center val c2 = remember { Point() } // Avatar center
val metaballWorkspace = remember { MetaballWorkspace() }
// Reusable RectF like Telegram's AndroidUtilities.rectTmp // Reusable RectF like Telegram's AndroidUtilities.rectTmp
val rectTmp = remember { RectF() } val rectTmp = remember { RectF() }
val blackPaint = remember {
android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.BLACK
style = android.graphics.Paint.Style.FILL
}
}
// Black bar height for no-notch fallback (Telegram's BLACK_KING_BAR) // Black bar height for no-notch fallback (Telegram's BLACK_KING_BAR)
val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() } val blackBarHeightPx = with(density) { BLACK_BAR_HEIGHT_DP.dp.toPx() }
@@ -484,24 +499,36 @@ fun ProfileMetaballOverlay(
val distance = avatarState.centerY - notchCenterY val distance = avatarState.centerY - notchCenterY
val maxDist = avatarSizeExpandedPx val maxDist = avatarSizeExpandedPx
val c = (distance / maxDist).coerceIn(-1f, 1f) val c = (distance / maxDist).coerceIn(-1f, 1f)
val connectorProgressRaw = ((dp40 - avatarState.radius) / (dp40 - dp18)).coerceIn(0f, 1f)
val connectorProgress = easeOutCubic(connectorProgressRaw)
val layerVisibilityBase = easeOutCubic((collapseProgress / 0.08f).coerceIn(0f, 1f))
val layerVisibility = lerpFloat(0.35f, 1f, layerVisibilityBase)
val baseV = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f) val baseV = ((1f - c / 1.3f) / 2f).coerceIn(0f, 0.8f)
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32) val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
val v = if (!avatarState.isNear) { val vBase = if (!avatarState.isNear) {
min(lerpFloat(0f, 0.2f, near), baseV) min(lerpFloat(0f, 0.2f, near), baseV)
} else { } else {
baseV baseV
} }.coerceIn(0.08f, 0.90f)
val v = lerpFloat(vBase * 0.92f, (vBase + 0.14f).coerceAtMost(0.98f), connectorProgress)
.coerceIn(0.08f, 0.98f)
// Show connector when avatar is small enough (isDrawing) and not expanding // Show connector when avatar is small enough (isDrawing) and not expanding
// No longer requires hasRealNotch — works with black bar fallback too // No longer requires hasRealNotch — works with black bar fallback too
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f val showConnector =
hasAvatar && expansionProgress == 0f && avatarState.showBlob &&
distance < maxDist * 1.6f && connectorProgress > 0.001f
// Show metaball layer when collapsing with avatar // Show metaball layer when collapsing with avatar
val showMetaballLayer = hasAvatar && expansionProgress == 0f val showMetaballLayer =
hasAvatar && expansionProgress == 0f && avatarState.showBlob &&
collapseProgress > 0.015f && layerVisibility > 0.01f
val connectorPaintAlpha = lerpFloat(96f, 255f, connectorProgress).toInt().coerceIn(0, 255)
// Adjusted blur radius based on device performance (factorMult) // Adjusted blur radius based on device performance (factorMult)
val adjustedBlurRadius = ProfileMetaballConstants.BLUR_RADIUS / factorMult val adjustedBlurRadius = (ProfileMetaballConstants.BLUR_RADIUS / factorMult) *
lerpFloat(0.9f, 1.45f, connectorProgress)
Box(modifier = modifier Box(modifier = modifier
.fillMaxSize() .fillMaxSize()
@@ -512,6 +539,9 @@ fun ProfileMetaballOverlay(
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.graphicsLayer {
alpha = layerVisibility
}
.graphicsLayer { .graphicsLayer {
// ColorMatrixColorFilter for alpha threshold (API 31+) // ColorMatrixColorFilter for alpha threshold (API 31+)
// Replaces AGSL RuntimeShader — same gooey effect // Replaces AGSL RuntimeShader — same gooey effect
@@ -528,38 +558,35 @@ fun ProfileMetaballOverlay(
.asComposeRenderEffect() .asComposeRenderEffect()
} }
) { ) {
val paint = android.graphics.Paint(android.graphics.Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.BLACK
style = android.graphics.Paint.Style.FILL
}
drawIntoCanvas { canvas -> drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas val nativeCanvas = canvas.nativeCanvas
// Draw target shape at top (notch or black bar fallback) // Draw target shape at top (notch or black bar fallback)
if (showConnector) { if (showConnector) {
blackPaint.alpha = connectorPaintAlpha
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
nativeCanvas.drawCircle( nativeCanvas.drawCircle(
notchCenterX, notchCenterX,
notchCenterY, notchCenterY,
notchRadiusPx, notchRadiusPx,
paint blackPaint
) )
} else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) { } else if (hasRealNotch && notchInfo != null && notchInfo.isAccurate && notchInfo.path != null) {
nativeCanvas.drawPath(notchInfo.path, paint) nativeCanvas.drawPath(notchInfo.path, blackPaint)
} else if (hasRealNotch && notchInfo != null) { } else if (hasRealNotch && notchInfo != null) {
val bounds = notchInfo.bounds val bounds = notchInfo.bounds
val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f val rad = kotlin.math.max(bounds.width(), bounds.height()) / 2f
nativeCanvas.drawRoundRect(bounds, rad, rad, paint) nativeCanvas.drawRoundRect(bounds, rad, rad, blackPaint)
} else { } else {
// No notch fallback: full-width black bar at top // No notch fallback: full-width black bar at top
// Like Telegram's ProfileGooeyView when notchInfo == null // Like Telegram's ProfileGooeyView when notchInfo == null
nativeCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, paint) nativeCanvas.drawRect(0f, 0f, screenWidthPx, blackBarHeightPx, blackPaint)
} }
} }
// Draw avatar shape - circle or rounded rect depending on state // Draw avatar shape - circle or rounded rect depending on state
if (avatarState.showBlob) { if (avatarState.showBlob) {
blackPaint.alpha = 255
val cx = avatarState.centerX val cx = avatarState.centerX
val cy = avatarState.centerY val cy = avatarState.centerY
val r = avatarState.radius val r = avatarState.radius
@@ -569,26 +596,28 @@ fun ProfileMetaballOverlay(
// If cornerRadius is close to radius, draw circle; otherwise rounded rect // If cornerRadius is close to radius, draw circle; otherwise rounded rect
if (cornerR >= r * 0.95f) { if (cornerR >= r * 0.95f) {
// Draw circle // Draw circle
nativeCanvas.drawCircle(cx, cy, r, paint) nativeCanvas.drawCircle(cx, cy, r, blackPaint)
} else { } else {
// Draw rounded rect (like Telegram when isDrawing) // Draw rounded rect (like Telegram when isDrawing)
// Reuse rectTmp to avoid allocations // Reuse rectTmp to avoid allocations
rectTmp.set(cx - r, cy - r, cx + r, cy + r) rectTmp.set(cx - r, cy - r, cx + r, cy + r)
nativeCanvas.drawRoundRect(rectTmp, cornerR, cornerR, paint) nativeCanvas.drawRoundRect(rectTmp, cornerR, cornerR, blackPaint)
} }
} }
// Draw metaball connector path // Draw metaball connector path
if (showConnector && avatarState.showBlob) { if (showConnector && avatarState.showBlob) {
blackPaint.alpha = connectorPaintAlpha
c1.x = notchCenterX c1.x = notchCenterX
c1.y = notchCenterY c1.y = notchCenterY
c2.x = avatarState.centerX c2.x = avatarState.centerX
c2.y = avatarState.centerY c2.y = avatarState.centerY
if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) { if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath, metaballWorkspace)) {
nativeCanvas.drawPath(metaballPath, paint) nativeCanvas.drawPath(metaballPath, blackPaint)
} }
} }
blackPaint.alpha = 255
} }
} }
} // END if (showMetaballLayer) } // END if (showMetaballLayer)
@@ -717,12 +746,12 @@ fun ProfileMetaballOverlayCompat(
val notchCenterY = blackBarHeightPx / 2f val notchCenterY = blackBarHeightPx / 2f
val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() } val notchRadiusPx = with(density) { ProfileMetaballConstants.FALLBACK_CAMERA_SIZE.toPx() }
// Telegram thresholds in pixels val thresholdScale = (avatarSizeExpandedPx / with(density) { 96.dp.toPx() }).coerceIn(1f, 1.35f)
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } * thresholdScale
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } * thresholdScale
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } * thresholdScale
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } * thresholdScale
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } * thresholdScale
// 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember // 🔥 FIX: Убраны volatile keys (collapseProgress, expansionProgress) из remember
// derivedStateOf автоматически отслеживает их как зависимости // derivedStateOf автоматически отслеживает их как зависимости
@@ -894,12 +923,12 @@ fun ProfileMetaballOverlayCpu(
} }
} }
// Thresholds val thresholdScale = (avatarSizeExpandedPx / with(density) { 96.dp.toPx() }).coerceIn(1f, 1.35f)
val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } val dp40 = with(density) { ProfileMetaballConstants.THRESHOLD_DRAWING.toPx() } * thresholdScale
val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } val dp34 = with(density) { ProfileMetaballConstants.THRESHOLD_SHAPE_END.toPx() } * thresholdScale
val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } val dp32 = with(density) { ProfileMetaballConstants.THRESHOLD_NEAR.toPx() } * thresholdScale
val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } val dp18 = with(density) { ProfileMetaballConstants.THRESHOLD_ALPHA_END.toPx() } * thresholdScale
val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } val dp22 = with(density) { ProfileMetaballConstants.ROUNDED_RECT_RADIUS.toPx() } * thresholdScale
val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) { val avatarState by remember(screenWidthPx, statusBarHeightPx, headerHeightPx, notchCenterX, notchCenterY, notchRadiusPx) {
derivedStateOf { derivedStateOf {
@@ -925,22 +954,35 @@ fun ProfileMetaballOverlayCpu(
val metaballPath = remember { android.graphics.Path() } val metaballPath = remember { android.graphics.Path() }
val c1 = remember { Point() } val c1 = remember { Point() }
val c2 = remember { Point() } val c2 = remember { Point() }
val metaballWorkspace = remember { MetaballWorkspace() }
val rectTmp = remember { RectF() } val rectTmp = remember { RectF() }
// Connector calculations // Connector calculations
val distance = avatarState.centerY - notchCenterY val distance = avatarState.centerY - notchCenterY
val maxDist = avatarSizeExpandedPx val maxDist = avatarSizeExpandedPx
val connectorProgressRaw = ((dp40 - avatarState.radius) / (dp40 - dp18)).coerceIn(0f, 1f)
val connectorProgress = easeOutCubic(connectorProgressRaw)
val layerVisibilityBase = easeOutCubic((collapseProgress / 0.08f).coerceIn(0f, 1f))
val layerVisibility = lerpFloat(0.35f, 1f, layerVisibilityBase)
val cParam = (distance / maxDist).coerceIn(-1f, 1f) val cParam = (distance / maxDist).coerceIn(-1f, 1f)
val baseV = ((1f - cParam / 1.3f) / 2f).coerceIn(0f, 0.8f) val baseV = ((1f - cParam / 1.3f) / 2f).coerceIn(0f, 0.8f)
val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32) val near = if (avatarState.isNear) 1f else 1f - (avatarState.radius - dp32) / (dp40 - dp32)
val v = if (!avatarState.isNear) min(lerpFloat(0f, 0.2f, near), baseV) else baseV val vBase = if (!avatarState.isNear) min(lerpFloat(0f, 0.2f, near), baseV) else baseV
val vBaseClamped = vBase.coerceIn(0.08f, 0.90f)
val v = lerpFloat(vBaseClamped * 0.92f, (vBaseClamped + 0.14f).coerceAtMost(0.98f), connectorProgress)
.coerceIn(0.08f, 0.98f)
val showConnector = hasAvatar && expansionProgress == 0f && avatarState.isDrawing && avatarState.showBlob && distance < maxDist * 1.5f val showConnector =
val showMetaballLayer = hasAvatar && expansionProgress == 0f hasAvatar && expansionProgress == 0f && avatarState.showBlob &&
distance < maxDist * 1.6f && connectorProgress > 0.001f
val showMetaballLayer =
hasAvatar && expansionProgress == 0f && avatarState.showBlob &&
collapseProgress > 0.015f && layerVisibility > 0.01f
val connectorPaintAlpha = lerpFloat(96f, 255f, connectorProgress).toInt().coerceIn(0, 255)
// CPU-specific: downscaled bitmap for blur // CPU-specific: downscaled bitmap for blur
// Reduced from 5 to 3 for higher resolution — smoother edges after alpha threshold // Higher internal resolution for cleaner, denser metaball edges.
val scaleConst = 3f val scaleConst = 2.5f
val optimizedW = min(with(density) { 120.dp.toPx() }.toInt(), screenWidthPx.toInt()) val optimizedW = min(with(density) { 120.dp.toPx() }.toInt(), screenWidthPx.toInt())
val optimizedH = min(with(density) { 220.dp.toPx() }.toInt(), headerHeightPx.toInt() + blackBarHeightPx.toInt()) val optimizedH = min(with(density) { 220.dp.toPx() }.toInt(), headerHeightPx.toInt() + blackBarHeightPx.toInt())
val bitmapW = (optimizedW / scaleConst).toInt().coerceAtLeast(1) val bitmapW = (optimizedW / scaleConst).toInt().coerceAtLeast(1)
@@ -982,7 +1024,13 @@ fun ProfileMetaballOverlayCpu(
) { ) {
// LAYER 1: CPU-rendered metaball shapes // LAYER 1: CPU-rendered metaball shapes
if (showMetaballLayer) { if (showMetaballLayer) {
Canvas(modifier = Modifier.fillMaxSize()) { Canvas(
modifier = Modifier
.fillMaxSize()
.graphicsLayer {
alpha = layerVisibility
}
) {
drawIntoCanvas { canvas -> drawIntoCanvas { canvas ->
val nativeCanvas = canvas.nativeCanvas val nativeCanvas = canvas.nativeCanvas
val optimizedOffsetX = (screenWidthPx - optimizedW) / 2f val optimizedOffsetX = (screenWidthPx - optimizedW) / 2f
@@ -1000,6 +1048,7 @@ fun ProfileMetaballOverlayCpu(
// Draw target (notch or black bar) // Draw target (notch or black bar)
if (showConnector) { if (showConnector) {
blackPaint.alpha = connectorPaintAlpha
if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) { if (hasRealNotch && notchInfo != null && notchInfo.isLikelyCircle) {
val rad = min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f val rad = min(notchInfo.bounds.width(), notchInfo.bounds.height()) / 2f
offscreenCanvas.drawCircle( offscreenCanvas.drawCircle(
@@ -1021,6 +1070,7 @@ fun ProfileMetaballOverlayCpu(
// Draw avatar shape // Draw avatar shape
if (avatarState.showBlob) { if (avatarState.showBlob) {
blackPaint.alpha = 255
val cx = avatarState.centerX val cx = avatarState.centerX
val cy = avatarState.centerY val cy = avatarState.centerY
val r = avatarState.radius val r = avatarState.radius
@@ -1036,20 +1086,23 @@ fun ProfileMetaballOverlayCpu(
// Draw metaball connector // Draw metaball connector
if (showConnector && avatarState.showBlob) { if (showConnector && avatarState.showBlob) {
blackPaint.alpha = connectorPaintAlpha
c1.x = notchCenterX c1.x = notchCenterX
c1.y = notchCenterY c1.y = notchCenterY
c2.x = avatarState.centerX c2.x = avatarState.centerX
c2.y = avatarState.centerY c2.y = avatarState.centerY
if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath)) { if (createMetaballPath(c1, c2, notchRadiusPx, avatarState.radius, v, metaballPath, metaballWorkspace)) {
offscreenCanvas.drawPath(metaballPath, blackPaint) offscreenCanvas.drawPath(metaballPath, blackPaint)
} }
} }
blackPaint.alpha = 255
offscreenCanvas.restore() offscreenCanvas.restore()
// Apply stack blur on CPU // Apply stack blur on CPU
val blurRadius = (ProfileMetaballConstants.BLUR_RADIUS * 2 / scaleConst).toInt().coerceAtLeast(1) val blurRadius = ((ProfileMetaballConstants.BLUR_RADIUS * 2f / scaleConst) *
lerpFloat(0.9f, 1.45f, connectorProgress)).toInt().coerceAtLeast(1)
stackBlurBitmapInPlace(offscreenBitmap, blurRadius) stackBlurBitmapInPlace(offscreenBitmap, blurRadius)
// Draw blurred bitmap with color matrix filter (alpha threshold) // Draw blurred bitmap with color matrix filter (alpha threshold)
@@ -1229,9 +1282,8 @@ fun MetaballDebugPanel(modifier: Modifier = Modifier) {
"cpu" -> "CPU (forced)" "cpu" -> "CPU (forced)"
"compat" -> "Compat (forced)" "compat" -> "Compat (forced)"
else -> when { else -> when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && perfClass >= PerformanceClass.AVERAGE -> "GPU (auto)" Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
perfClass >= PerformanceClass.HIGH -> "CPU (auto)" else -> "CPU (auto)"
else -> "Compat (auto)"
} }
} }
Text( Text(
@@ -1287,32 +1339,38 @@ fun ProfileMetaballEffect(
"cpu" -> "CPU (forced)" "cpu" -> "CPU (forced)"
"compat" -> "Compat (forced)" "compat" -> "Compat (forced)"
else -> when { else -> when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE -> "GPU (auto)" Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> "GPU (auto)"
performanceClass >= PerformanceClass.HIGH -> "CPU (auto)" else -> "CPU (auto)"
else -> "Compat (auto)"
} }
} }
LaunchedEffect(selectedPath) { val debugLogsEnabled = MetaballDebug.forceMode != null || MetaballDebug.forceNoNotch
LaunchedEffect(selectedPath, debugLogsEnabled, performanceClass) {
if (debugLogsEnabled) {
Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}") Log.d("MetaballDebug", "Rendering path: $selectedPath, forceNoNotch: ${MetaballDebug.forceNoNotch}, perf: $performanceClass, API: ${Build.VERSION.SDK_INT}")
} }
}
// Resolve actual mode // Resolve actual mode
val useGpu = when (MetaballDebug.forceMode) { val useGpu = when (MetaballDebug.forceMode) {
"gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31 "gpu" -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // still need API 31
"cpu" -> false "cpu" -> false
"compat" -> false "compat" -> false
else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && performanceClass >= PerformanceClass.AVERAGE else -> Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
} }
val useCpu = when (MetaballDebug.forceMode) { val useCpu = when (MetaballDebug.forceMode) {
"gpu" -> false "gpu" -> false
"cpu" -> true "cpu" -> true
"compat" -> false "compat" -> false
else -> !useGpu && performanceClass >= PerformanceClass.HIGH else -> !useGpu
} }
when { when {
useGpu -> { useGpu -> {
val factorMult = if (performanceClass == PerformanceClass.HIGH) 1f else 1.5f val factorMult = when (performanceClass) {
PerformanceClass.HIGH -> 1f
PerformanceClass.AVERAGE -> 1.2f
PerformanceClass.LOW -> 1.35f
}
ProfileMetaballOverlay( ProfileMetaballOverlay(
collapseProgress = collapseProgress, collapseProgress = collapseProgress,
expansionProgress = expansionProgress, expansionProgress = expansionProgress,