本文译自「Compose AGSL Shader: Gooey Outline Metaball Effect with Transparent Background」,原文链接medium.com/@yuriyskul/…,由Yuriy Skul发布于2024年12月2日。
本文探讨如何通过两个圆形之间的简单交互创建元球效果。元球形状带有边框,而背景保持完全透明,从而确保底层内容的可见性。元球内部的图标也保持可见。此效果是通过对整个容器应用高级AGSL 着色器,并结合对每个交互元素应用模糊效果来实现的。
创建带有轮廓效果的透明边框元球背景的概念可以参考这篇文章:在 Jetpack Compose 中使用模糊和 AGSL 运行时渲染效果链创建带有轮廓描边边框的透明元球背景。
圆形按钮的 Gooey Metaball 特效:
-
第一部分:使用 Jetpack Compose 在 Android 上实现 Gooey(Metaball)特效:跨所有 API 级别的模糊和 Alpha 过滤概念。
-
第三部分:Jetpack Compose:使用 AGSL 着色器和模糊效果实现 Gooey(元球)交互——修复颜色问题。
-
第四部分:当前状态。
需求:
-
不透明边框:元球形状应具有特定颜色的不透明边框,例如黑色。
-
透明内背景:元球的内背景应完全透明,以确保整个区域的背景完全不受影响且可见。
-
可见按钮图标:元球内部的图标必须保持完全不透明,并具有定义的颜色且可见。
-
处理折叠后的元球:当元球折叠成一个点时,应仅保留最顶部的图标可见,以确保折叠状态下的清晰度。
让我们从第一部分中的第一个AGSL基础示例的代码开始。在这个示例中,我们将AGSL着色器效果应用于父级MetaballBox,并为每个圆形按钮添加了模糊效果:
正如我在创建透明边框metaball背景的概念中所述,为metaball创建边框的思路是在AGSL脚本中使用阈值透明度范围进行过滤。透明度值落在指定范围内的像素将被设置为完全不透明颜色,而所有其他像素则被设置为完全透明,从而形成清晰锐利的边界效果。
这并非本文的主要关注点。然而,由于元球形状内部包含不透明图标,因此会出现一些问题。为了解决这些问题,本文将探讨一些变通方法和技巧,以满足上述要求。
AGSL:颜色测试探究
让我们运行测试着色器,并根据每个点的输入alpha值为其分配特定颜色。着色器逻辑应用以下规则:
- 如果 alpha = 0.0:
该点完全透明,为了便于调试,被标记为黄色。
- 如果 alpha < cutoff_min:
该点位于元球的内部区域,并被标记为绿色以示区别。
- 如果 alpha 介于 cutoff_min 和 cutoff_max 之间:
该点位于边界区域,并被标记为黑色,以标记过渡区域。
- 如果 alpha > cutoff_max 但小于 1.0:
该点位于外部混合区域,透明度开始逐渐减弱。它被标记为蓝色以表示此范围。
- 如果 alpha = 1.0:
该点完全不透明,并以红色标记其最大不透明度。
cutoff_min = 0.5f
cutoff_max = 0.6f
@Language("AGSL")
const val ShaderSource_outlined_color_test_1 = """
uniform shader composable;
uniform float cutoff_min; // Minimum cutoff value for transparency
uniform float border_thickness; // Thickness of the border
uniform float3 rgbColor; // Custom RGB color (not directly used in this logic)
half4 main(float2 fragCoord) {
half4 color = composable.eval(fragCoord);
// Calculate cutoff_max dynamically using border_thickness
float cutoff_max = cutoff_min + border_thickness;
// If alpha is exactly 0.0 (fully transparent), set it to yellow
if (color.a == 0.0) {
color.rgb = half3(1.0, 1.0, 0.0); // Yellow for fully transparent
}
// If alpha is below the minimum cutoff, make it green
else if (color.a < cutoff_min) {
color.rgb = half3(0.0, 1.0, 0.0); // Green
}
// If alpha is within the cutoff range (border area), make it black
else if (cutoff_min <= color.a && color.a <= cutoff_max) {
color.rgb = half3(0.0, 0.0, 0.0); // Black
}
// If alpha is above the maximum cutoff but less than fully opaque (outer area), make it blue
else if (cutoff_max < color.a && color.a < 1.0) {
color.rgb = half3(0.0, 0.0, 1.0); // Blue
}
// Fully opaque case where alpha = 1, make it red
else {
color.rgb = half3(1.0, 0.0, 0.0); // Red
}
// Ensure alpha is fully opaque for all cases
color.a = 1.0;
return color;
}
"""
模糊半径 = 16dp 时的结果(元球大小 = 100dp)
如前所述,本文的重点并非创建边框(黑色区域),而是解决与内部透明部分相关的额外要求,同时保持图标不透明。难点在于如何根据透明度进行过滤,以确定给定点属于图标还是背景。
但在解决这个问题之前,我们先来了解一下为什么在图像内部会看到一个红色圆圈(一个完全不透明的像素)。
假设我们有一个半径为 50 dp 的某种颜色的完全不透明的圆,并应用一个半径很小的模糊效果(1-2 像素),结果会显示这个完全不透明的圆会比原来的 50 dp 半径略小一些。需要强调的是,“完全不透明的圆”指的是圆内所有 alpha 值恰好为 1.0(完全不透明)的点,而 alpha 值小于 1.0(半透明或完全透明)的点则不属于这个圆。模糊半径越大,得到的完全不透明的圆就越小。本质上,模糊效果起到了一种柔化或扩散的作用,从而缩小了模糊圆的尺寸。
如果模糊半径大于不透明圆的半径会发生什么呢?在这种情况下,圆内将不再存在完全不透明的像素,因为模糊会将不透明度完全分散到整个区域,只留下半透明像素。
实际上,要完全模糊一个完全不透明的圆并移除所有不透明像素,我们需要应用一个大于 Metaball 圆形形状的半径/2的模糊半径。
在这种情况下,Metaball 背景将不再包含任何完全不透明的像素。但是,由于图标渲染在一个单独的图层上(参见MetaballBox的实现),该图层未应用模糊效果,因此图标像素的不透明度将保持不变,alpha = 1.0。
Metaball 圆形按钮 = 100 dp,因此 R = 50 dp。
让我们设置一个大于 R/2 + 1 dp = 25 dp 的模糊半径。
对于 25 dp 的模糊半径,仍然会存在一个小的内部不透明点。为了确保不留下任何完全不透明的像素,我们将模糊半径设置为 26.dp。
越来越接近目标了!在按钮展开状态下,我们可以使用 alpha = 1 来过滤图标。但是,当按钮折叠时,模糊的元球层会产生累积效应,在给定的模糊半径下,最终完全不透明像素的区域会变大。我不确定具体的计算公式,而且这并不特别重要,因为我会在文章后面提出不同的解决方案。现在,我们先采用一个变通方法,进一步增大模糊半径。
应用模糊半径 = 36.dp:
简易 AGSL:按 Alpha 值过滤区域
我们来设置输入 RGB 颜色,并将 Alpha 值设为 1,用于红色区域(图标)和黑色区域(边框)。
然后,将透明度(Alpha = 0)应用于所有其他区域。但这里有个小细节:
在我的示例中,我使用黑色作为圆形按钮的颜色,但需要注意的是,黑色是“零”色 (0, 0, 0)。如果圆形按钮的背景色是任何非“零”色,例如绿色,那么我们通过将 Alpha 通道设置为 0 来使其完全透明的模糊半透明区域仍然会影响混合效果。这种混合效果的出现是因为颜色分量(RGB)的值不为零,从而导致出现意想不到的视觉效果,例如:
因此,如果我们希望某个像素完全透明,并且背景图层不受影响,不会产生任何副作用,我们不仅应该将 alpha = 0 设置为 0,还应该将 “零”RGB 颜色 设置为黑色 (0, 0, 0)。
之前,我使用的边框粗细为 0.1f。为了使边框更细,我们将其减小到 0.05f。
@Language("AGSL")
const val ShaderSource_outlined_simple = """
uniform shader composable;
uniform float cutoff_min; // Minimum cutoff value for transparency
uniform float border_thickness; // Thickness of the border
uniform float3 rgbColor; // RGB color passed as parameter
// Constant for black (zero) RGB color
const half3 BLACK_MASK_RGB = half3(0.0, 0.0, 0.0);
half4 main(float2 fragCoord) {
half4 color = composable.eval(fragCoord);
// Calculate cutoff_max dynamically using border_thickness
float cutoff_max = cutoff_min + border_thickness;
// If alpha is exactly 0.0 (fully transparent), do nothing
if (color.a == 0.0) {
// Do nothing
}
// If alpha is below the minimum cutoff,
// make it fully transparent and black
else if (color.a < cutoff_min) {
color.rgb = BLACK_MASK_RGB; // Set to black
color.a = 0.0; // Fully transparent
}
// If alpha is within the cutoff range (border area),
// make it the custom color
else if (cutoff_min <= color.a && color.a <= cutoff_max) {
color.rgb = rgbColor; // Set to custom color
color.a = 1.0; // Fully opaque
}
// If alpha is above the maximum cutoff but less than
// fully opaque (outer area), make it fully transparent and black
else if (cutoff_max < color.a && color.a < 1.0) {
color.rgb = BLACK_MASK_RGB; // Set to black
color.a = 0.0; // Fully transparent
}
// Fully opaque case where alpha = 1 (Icon area),
// make it the custom color
else {
color.rgb = rgbColor; // Set to custom color
color.a = 1.0; // Fully opaque
}
return color;
}
"""
结果:
由于内部背景是透明的,我们需要解决图标重叠问题。一个快速的解决方案是隐藏下方按钮的图标,同时保持上方按钮的图标可见。
我们可以跟踪按钮的偏移量,当偏移量小于20dp时,将下方图标的可见性状态设置为false。相反,当偏移量大于20dp时,将其设置为true。
为了优化,将此逻辑封装在 **derivedStateOf** 中。此外,将按钮图标封装在 **AnimatedVisibility** 中。或者,你可以尝试根据按钮之间的偏移量手动更改 alpha 色调。
但是,如果我们希望图标的可见性过渡平滑,这种方法就行不通了。在下面的代码片段中,我将展开动画的持续时间设置为 7 秒,将隐藏动画的持续时间设置为 4 秒。但由于 AGSL 会根据 alpha 值进行过滤,一旦图标的 alpha 值逐渐低于 1,它就会立即完全透明。在展开动画期间,图标不会逐渐淡入——它会一直保持不可见,直到 4 秒动画结束。此时,当 AnimatedVisibility 容器将其 alpha 值设置为完全不透明(alpha = 1)时,图标会立即弹出。
@Composable
fun ExampleAGSLOutlineContent(
modifier: Modifier = Modifier
) {
var isExpanded by remember { mutableStateOf(false) }
val offsetDistance = (80).dp
val buttonOffset by animateDpAsState(
targetValue = if (isExpanded) offsetDistance else 0.dp,
animationSpec = tween(durationMillis = 7000)
)
val color = Color.Black
//test visibility start
var isVisible by remember { mutableStateOf(false) } // Another state for visibility
// Derived state to update visibility based on buttonOffset
val derivedVisibility = remember(buttonOffset) {
derivedStateOf { buttonOffset > 40.dp }
}
// Observe derived state and update visibility
LaunchedEffect(derivedVisibility.value) {
isVisible = derivedVisibility.value
}
//
val blurRadius = 16.dp
//
val blurRadius = 26.dp
val blurRadius = 36.dp
OutlineShaderMetaballBox(
modifier = modifier,
color = color
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp)
) {
BlurCircularButton(
Modifier.offset { IntOffset(x = -buttonOffset.roundToPx(), y = 0) },
onClick = { isExpanded = !isExpanded },
color = color,
blur = blurRadius,
) {
AnimatedVisibility(
visible = isVisible,
enter = fadeIn(animationSpec = tween(durationMillis = 4000)),
exit = fadeOut(animationSpec = tween(durationMillis = 4000)),
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.Delete,
contentDescription = null,
tint = color,
)
}
}
BlurCircularButton(
Modifier
.offset { IntOffset(x = +buttonOffset.roundToPx(), y = 0) },
onClick = { isExpanded = !isExpanded },
color = color,
blur = blurRadius,
) {
Icon(
modifier = Modifier.size(28.dp),
imageVector = Icons.Filled.Build,
contentDescription = null,
tint = color,
)
}
}
}
}
图标可见性过渡闪烁不流畅:
对于快速展开/折叠动画和可见性过渡的持续时间,例如 150 毫秒 或 300 毫秒,此问题可能不明显,并且可以正常工作。
-
实现简单:AGSL 脚本非常简单,仅依赖于输入像素颜色的 Alpha 通道进行过滤。
-
对最小模糊半径有严格的依赖性。合并的元素越多,就越难计算或通过实验确定稳定实现所需的最小可接受模糊半径。这使得该方法不可靠,不适合实现健壮稳定的效果。
-
由于 AGSL 脚本中基于 alpha 通道的滤波特性,图标的平滑出现和消失难以实现。
-
随着元球交互中涉及的元素数量增加,以及膨胀物理机制的复杂性提升,确定和实现图标出现或消失的精确时机变得困难,进一步增加了控制的复杂性和整体逻辑的复杂性。
让我们寻找更好的解决方案:
高级 AGSL:按 Alpha 值和标记颜色过滤区域
让我们再次回到较小的模糊半径 16dp。
我们需要检测红色不透明区域中的像素是否属于图标。
我建议使用一个额外的 RGB 颜色作为标记。唯一的要求是,对于应用 AGSL 效果的容器及其子元素,该颜色必须是唯一的。
我们使用青色 RGB作为标记颜色。使用 Color.Cyan 为图标着色,并将相同的青色作为参数传递给 AGSL 脚本。
在 AGSL 中定义以下参数:
AGSL 着色器源代码的结构与上一章相同,唯一的区别在于最后一个 else 代码块,其中 alpha 值为 1,代表测试屏幕上的中心红色。
在不透明部分,检查像素的 RGB 颜色是否等于 markerColor。如果为真,则它是图标像素——你可以将其着色为任何你想要的颜色。设置 alpha = 1 是多余的,因为我们已经在不透明的 else 语句块中了。
否则,如果为假,则它肯定不是图标像素——通过将 RGB 值设置为黑色并将 alpha = 0 设置为 0 使其完全透明。
完整的 AGSL 脚本:
@Language("AGSL")
const val ShaderSource_outlined_marker_color = """
uniform shader composable;
uniform float cutoff_min; // Minimum cutoff value for transparency
uniform float border_thickness; // Thickness of the border
uniform float3 rgbColor; // RGB color passed as parameter
uniform float3 rgbMarkerColor; // marker for Icon tint color
// Constant for black (zero) RGB color
const half3 BLACK_MASK_RGB = half3(0.0, 0.0, 0.0);
half4 main(float2 fragCoord) {
half4 color = composable.eval(fragCoord);
// Calculate cutoff_max dynamically using border_thickness
float cutoff_max = cutoff_min + border_thickness;
// If alpha is exactly 0.0 (fully transparent), do nothing
if (color.a == 0.0) {
// Do nothing
}
// If alpha is below the minimum cutoff,
// make it fully transparent and black
else if (color.a < cutoff_min) {
color.rgb = BLACK_MASK_RGB; // Set to black
color.a = 0.0; // Fully transparent
}
// If alpha is within the cutoff range (border area),
// make it the custom color
else if (cutoff_min <= color.a && color.a <= cutoff_max) {
color.rgb = rgbColor; // Set to custom color
color.a = 1.0; // Fully opaque
}
// If alpha is above the maximum cutoff but less than
// fully opaque (outer area), make it fully transparent and black
else if (cutoff_max < color.a && color.a < 1.0) {
color.rgb = BLACK_MASK_RGB; // Set to black
color.a = 0.0; // Fully transparent
} else {
// Fully opaque case where alpha = 1
if(color.rgb == rgbMarkerColor){
//Icon area
color.rgb = rgbColor; // Set to Icon color
}else{
//pixel is out of icon shape - make it transparent
color.rgb = BLACK_MASK_RGB;
color.a = 0.0;
}
}
return color;
}
"""
以下是三种情况的解决方案示例:
-
当图标中心部分在折叠和展开状态下均存在不透明边框时(blurRadius = 16dp)。
-
当图标在展开状态下仅形状不透明,但在折叠状态下仍然存在不透明边框时(blurRadius = 26dp)。
-
当模糊半径足够大,以至于在展开和折叠状态下均不存在不透明边框时(blurRadius = 36dp)。
请注意,尽管所有示例均使用 borderAlphaThickness = 0.05f 执行,但增加 blurRadius 的值也会增加边框的粗细。
-
AGSL 的实现相对简单,满足文章开头概述的要求。
-
重叠部分会自动处理,因此折叠时无需手动隐藏底层图标。
-
应该也能处理更多数量的元球。
-
可以很好地适应不同的模糊半径。
-
如果传递了额外的标记颜色参数但未正确设置为色调颜色,则该参数可能会成为潜在的错误来源。
-
必须确保标记颜色具有唯一的 RGB 值,并且与用于模糊效果的颜色不同。
-
消失的重叠图标就像一个不可见的透明图层。虽然这对于 Material 3(更抽象)来说没有问题,但对于 Material Design 2 的老用户来说,这可能会破坏设计理念。
高级 AGSL:按 Alpha 值、标记颜色和计算亮度过滤区域。
上一章的解决方案,持续时间更长,并略微放大:
左侧图标在清晰的、不可见的边框下方突然消失。我们通过使其在接近边框时平滑淡出,并在折叠状态下完全消失来修复此问题。
基于上一章中 else 语句(本例中为 alpha = 1)检查是否与 colorMarker 值相等,我将进行修改,增加一项检查是否与黑色 RGB 值相等的检查,并将 else 语句留空:
在折叠过程中,我们看到左侧(后方)图标的颜色接近 rgbMarkerColor(Color.CYAN),该图标部分被顶部的元球形状覆盖。然而,情况略有不同,因此条件 color.rgb == rgbMarkerColor 不匹配。随着图标逐渐靠近,这种“接近标记颜色”的颜色会逐渐过渡到接近黑色(即用于模糊效果的元球圆形按钮背景色)。
颜色并非完全是青色的原因在于混合效果。当左侧图标被右侧元球的模糊图层覆盖时,标记颜色会发生扭曲。根据标记颜色上方黑色模糊层的透明度,我们看到的颜色要么非常接近标记颜色,要么更接近黑色。
从视频中,我们可以观察到颜色之间的平滑过渡。
为了最大程度地增强亮度变化的效果,我们使用亮度最高的 MarkerColor——纯白色 (1, 1, 1)。最终颜色由用于模糊效果的圆形按钮背景色决定,在本例中为黑色——这非常适合接下来的解决方案。
将MarkerColor 更改为白色:
在最终的 GIF 动画中,当 markerColor = (1, 1, 1)(白色)时,我们可以看到,左侧图标在与右侧元球重叠并逐渐靠近的过程中,其 RGB 值从接近白色的完全不透明过渡到接近黑色的完全不透明。
本章的解决方案是利用亮度从亮到暗的变化,并将这些变化转换为透明度从不透明到透明的变化。
当模糊半径非常小时,元球中心呈现不透明的圆形(我在第一章“AGSL:颜色测试探究”中提到的区域),这种方法效果很好(半径为 16dp)。但是,当使用较大的半径时,在折叠状态下,只有属于图标的不透明像素没有边框,此时会出现 UI 问题。
让我们用大于 36dp 的模糊值来测试一下:
让我们创建一个名为 adjustAlpha 的辅助函数来动态调整 alpha 通道。该函数以当前的 alpha 值作为输入,并执行两个关键操作:
-
指数运算:将 alpha 值提升到 4 次方。此操作通过增强较高的 alpha 值并更快地降低较小的 alpha 值,来帮助平滑过渡。
-
阈值处理:指数运算后,函数会检查调整后的 alpha 值是否小于预定义的阈值(例如
0.01)。如果小于,则返回0.0(完全透明);否则,返回调整后的 alpha 值。
这种操作组合确保即使是较小的 alpha 值也能平滑过渡到完全透明,而不会出现突兀的视觉瑕疵。
通过使用此辅助函数,我可以轻松地平滑处理 alpha 过渡。
此外,当调整后的透明度为 0 时,我们将 RGB 值设置为遮罩颜色(例如黑色)。如果不是 0,则函数会赋值 rgbColor。在当前示例中,rgbColor 也为黑色,但如果我们使用不同的颜色进行模糊处理,此逻辑可确保正确处理透明度。
完整的 AGSL 脚本:
@Language("AGSL")
const val ShaderSource_outlined_marker_color_and_marker_brightness = """
uniform shader composable;
uniform float cutoff_min; // Minimum cutoff value for transparency
uniform float border_thickness; // Thickness of the border
uniform float3 rgbColor; // RGB color passed as a parameter
uniform float3 rgbMarkerColor; // Marker for Icon tint color
// Constant for black (zero) RGB color
const half3 BLACK_MASK_RGB = half3(0.0, 0.0, 0.0);
// Helper function to adjust alpha dynamically
float adjustAlpha(float alpha) {
// Define the threshold value
const float threshold = 0.01;
// Calculate the alpha raised to the power of 4
float adjustedAlpha = pow(alpha, 4.0);
// Return either the adjusted alpha or 0 if below the threshold
return adjustedAlpha < threshold ? 0.0 : adjustedAlpha;
}
half4 main(float2 fragCoord) {
half4 color = composable.eval(fragCoord);
// Calculate cutoff_max dynamically using border_thickness
float cutoff_max = cutoff_min + border_thickness;
// If alpha is exactly 0.0 (fully transparent), do nothing
if (color.a == 0.0) {
// Do nothing
}
// If alpha is below the minimum cutoff, make it fully transparent and black
else if (color.a < cutoff_min) {
color.rgb = BLACK_MASK_RGB; // Set to black
color.a = 0.0; // Fully transparent
}
// If alpha is within the cutoff range (border area), make it the custom color
else if (cutoff_min <= color.a && color.a <= cutoff_max) {
color.rgb = rgbColor; // Set to custom color
color.a = 1.0; // Fully opaque
}
// If alpha is above the maximum cutoff but less than fully opaque (outer area),
// make it fully transparent and black
else if (cutoff_max < color.a && color.a < 1.0) {
color.rgb = BLACK_MASK_RGB; // Set to black
color.a = 0.0; // Fully transparent
}
// Fully opaque case where alpha = 1
else {
if (color.rgb == rgbMarkerColor) {
// Icon area
color.rgb = rgbColor; // Set to Icon color
} else if (color.rgb == rgbColor) {
// Some black inner metaball area could be opaque - make it transparent
color.rgb = BLACK_MASK_RGB;
color.a = 0.0;
} else {
// Compute current brightness
float brightness = (color.r + color.g + color.b) / 3.0;
float adjustedBrightness = adjustAlpha(brightness);
// Set brightness as the alpha channel
color.a = adjustedBrightness;
// Set RGB color based on adjusted brightness
if (adjustedBrightness == 0) {
color.rgb = BLACK_MASK_RGB; // Fully transparent
} else {
color.rgb = rgbColor; // Custom color
}
}
}
return color;
}
"""
-
效果极佳。符合 UI 要求,确保当元球按钮靠近时,图标平滑消失。
-
复杂逻辑:此实现引入了更多复杂性,尤其是在处理亮度到透明度的计算方面,这可能会增加出现错误的可能性。
-
模糊半径限制:非常大的模糊半径值需要通过亮度(AGSL:
adjustAlpha())微调透明度计算,以保持预期行为。 -
可扩展性问题:当处理两个以上的元球元素时,可能需要对透明度计算函数进行额外调整。
-
标记和模糊颜色限制:标记颜色必须为纯白色,而模糊颜色必须为纯黑色,以避免混合不一致。
-
设计依赖性:所有必需的颜色,包括图标颜色、边框颜色和内部区域的透明度,都必须通过 AGSL 着色器显式设置,这增加了对着色器逻辑的依赖性。
当前可组合部分的 GitHub 链接 以及 AGSL 着色器链接。
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!