本文译自「Metaballs with Runtimeshaders」,原文链接medium.com/@off.mind.b...,由Alex Volkov发布于2025810。

大家好!今天我想向大家介绍一种最简单却又出人意料地令人印象深刻的效果:元球。
元球是一种看起来像有机体的形状,其特点是它们能够在近距离内融合在一起,形成单个 连续的物体。
使用 GLSL 着色器创建这种效果非常简单,只需几行代码即可。当然,你也可以在网上找到关于如何将其移植到 AGSL 并在 Compose 中使用的教程。然而,主要的问题是这种效果需要两个(或更多)物体。我找到的所有教程都只是在单个着色器中模拟两个组件。这种方法在视觉上很有效,但在实际项目中使用时会带来很多限制。因此,我开始以一种每个元素只扭曲自身的方式来构建这种效果------尽可能地让一切看起来公平、干净。
好了,既然我们已经明白了为什么这不仅仅是一个metaball教程,而是一个全新的教程------那就开始吧!和往常一样,我把所有内容分解成几个部分:
- 首先,我会快速解释一下这种效果在经典实现中的工作原理,然后我们会进行哪些不同的调整。
- 然后,我会介绍一下Compose的简单设置,让它运行起来。
- 最后,我们会更详细地介绍着色器本身。
最终,我们应该得到如下所示的效果:

开始之前,先简单说明一下------我不会为我制作的每个效果都制作教程,但你可以在我的 GitHub 上找到所有教程。你还可以在我的 Telegram 频道 上找到视频、新效果公告以及问题的解答。期待在那里见到你!
要创建元球效果,我们先来回顾一下基础知识。如何在着色器中绘制一个简单的圆圈?最简单的方法是定义一个中心和一个半径,然后使用 step 函数。如果像素比半径更靠近中心,则返回 1;如果像素比半径更远离中心,则返回 0。实际代码如下:
glsl
float ball( vec2 p, vec2 center, float radius ) {
return step(length(p-center), radius);
}
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
// Normalized pixel coordinates (from -0.5 to 0.5)
vec2 uv = fragCoord/iResolution.xy - 0.5;
uv.x *= iResolution.x / iResolution.y;
float b1 = ball(uv, vec2(0.), 0.2);
vec3 color = b1*vec3(1.);
fragColor = vec4(color,1.0);
}
这段代码是用 GLSL 编写的,而不是 AGSL,你可以直接复制粘贴到 shadertoy.com 中。在本节中,所有代码都将采用相同的方法,让你无需运行 Android Studio 即可更轻松地测试和查看结果。
结果是一个最简单的圆圈。如果你对着色器稍有了解,这部分应该很容易理解。

现在,让我们添加第二个圆圈,并将它们水平放置,使它们略微分开,而不是同时位于中心。这次,我们不再使用步长函数来定义边缘,而是使用反距离。我们仍然会得到两个圆圈,但边缘不再是锐利的,而是平滑的渐变,使圆圈看起来更像发光的点。
glsl
float ball(vec2 p, vec2 center, float radius) {
float dist = length(p - center);
return radius / dist;
}
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
// Normalized pixel coordinates (from -0.5 to 0.5)
vec2 uv = fragCoord/iResolution.xy - 0.5;
uv.x *= iResolution.x / iResolution.y;
float b1 = ball(uv, vec2(-0.4,0.), 0.1);
float b2 = ball(uv, vec2(0.4,0.), 0.1);
float circles = b1 + b2;
vec3 color = circles*vec3(1.);
fragColor = vec4(color,1.0);
}
最终效果如下:

如你所见,在最终图像中,我们将两个圆的值相加。即使现在,如果你将它们移近,你也会看到它们开始合并。基本上,效果已经存在------剩下的就是对最终值应用一些函数,或者再次使用 step 函数截断低于特定阈值的所有内容,保留其内部内容。就这样------元球效果完成了!
glsl
float ball(vec2 p, vec2 center, float radius) {
float dist = length(p - center);
return radius / dist;
}
void mainImage( out vec4 fragColor, in vec2 fragCoord )
{
// Normalized pixel coordinates (from -0.5 to 0.5)
vec2 uv = fragCoord/iResolution.xy - 0.5;
uv.x *= iResolution.x / iResolution.y;
// make circles moving slightly along horizontal direction
float horiz = (0.5*sin(iTime)+0.5)*0.2;
float b1 = ball(uv, vec2(-0.3+horiz,0.), 0.1);
float b2 = ball(uv, vec2(0.3-horiz,0.), 0.1);
float circles = b1 + b2;
float threshold = 1.0;
float alpha = step(threshold, circles);
vec3 color = alpha*vec3(1.);
fragColor = vec4(color,1.0);
}

当然,除了阶跃函数,你还可以使用 SmoothStep,添加距离的平方反比,或者尝试不同的公式,调整系数等等。所有这些都是可能的,并且都会改变最终的外观,但核心思想保持不变:我们用平滑衰减公式定义圆,将结果相加,并在某个阈值处进行截断。这样,当形状接近时,它们的场之和会超过阈值,从而合并成一个形状------而当它们相距较远时,则不会合并。
现在我们已经了解了如何创建元球效果,看起来我们只需打开 Android Studio 就可以开始构建了!然而,我们很快就会遇到两个问题:
- 目前,所有按钮都像圆形一样工作------这是意料之中的。但如果我们希望合并按钮具有其他形状,则需要使用例如 SDF 方法。即便如此,形状仍然在着色器内部定义,这意味着我们不能简单地在 Compose 视图中应用 RoundedCornerShape 并期望它能够正常工作。
- 两个按钮必须在同一个着色器中定义。如果有三个按钮,则三个按钮都必须位于同一个着色器中。最重要的是------我们如何处理这些按钮的点击?
如果你查找有关此效果的文章,你会发现这两个问题通常被忽略。然而,对我来说,它们至关重要------这正是我决定撰写本教程的原因。我建议采用一种不同的方法来解决这两个问题。
首先,我们不会在着色器内部定义形状。相反,我们将变形坐标系本身,从而解决不同形状的问题。
其次,效果中的每个元素都会获得自己的着色器实例,但我们也会将相邻元素的坐标传递给它。这解决了点击处理问题,因为现在每个元素都是一个独立的可组合元素,拥有自己的属性和 lambda 表达式。下图所示:

因此,我们的任务可以分为两个主要的子任务。一旦解决了这两个子任务,我们就能得到最终的效果。首先,我们需要学习如何变形画布以获得与元球相同的视觉效果。然后,我们将收集容器中每个子元素的位置数据,并将其传递给其他着色器。让我们先从第一部分开始。
让我们从使用着色器所需的最简单的设置开始。下面是可组合部分------你可以直接复制粘贴到你的项目中,它应该可以立即运行。
kotlin
@Composable
fun MetaballShaderScreen(paddingValues: PaddingValues) {
val shader = remember { RuntimeShader(metaballShader) }
Box(modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.background(Color(0xFF171717)),
contentAlignment = Alignment.Center
) {
ShadedButton(shader)
}
}
@Composable
fun ShadedButton(
shader: RuntimeShader,) {
var boxSize by remember { mutableStateOf(IntSize.Zero) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(50.dp)
) {
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged { boxSize = it }
.graphicsLayer {
shader.setFloatUniform(
"resolution",
boxSize.width.toFloat(),
boxSize.width.toFloat())
this.renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "image")
.asComposeRenderEffect()
}
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(10.dp))
.background(color = Color(0xFFF6F6F6))
)
}
Icon(
imageVector = Icons.Default.MoreVert,
contentDescription = "Expand Menu",
tint = Color.Black
)
}
}
以下是着色器代码:
kotlin
@Language("AGSL")
private val metaballShader = """
uniform vec2 resolution;
uniform shader image;
vec2 NormalizeCoordinates(vec2 o, vec2 r) {
float2 uv = o / r - 0.5;
if (r.x >= r.y) {
uv.x *= r.x / r.y;
} else {
uv.y *= r.y / r.x;
}
return uv;
}
vec4 GetImageTexture(vec2 p, vec2 pivot, vec2 r) {
if (r.x > r.y) {
p.x /= r.x / r.y;
} else {
p.y /= r.y / r.x;
}
p += pivot;
p *= r;
return image.eval(p);
}
vec4 main(float2 fragCoord) {
float2 uv = NormalizeCoordinates(fragCoord, resolution);
vec4 final = GetImageTexture(uv, vec2(0.5), resolution);
return vec4(final);
}""".trimIndent()
在着色器中,我已经包含了两个必要的方法:一个用于规范化,一个用于从输入纹理中获取颜色。我在之前的课程中介绍过这些方法,因此你可以简单地将它们视为必需的样板代码------它们不会影响效果的核心逻辑------或者查看我之前的教程,我在那里详细解释了它们。本课程已经相当丰富,甚至可能信息量过大,所以我在这里就不赘述了。
太好了!如果一切设置正确,我们将得到一个尚未添加任何内容的着色器------它只是绘制所有内容,就像着色器根本不存在一样。结果应该只是一个普通的按钮,没什么特别的:

在最终的代码中,变形将基于其他元素,但由于我们目前还没有这些元素,我只需添加一个虚拟点并将其绘制在画布上即可。这只是测试代码,我们稍后会将其移除。我还会将时间传递给着色器,并使该点水平移动。这将是我们的虚拟点,我们将使用它作为画布变形的参考:
kotlin
//...
var time by remember { mutableStateOf(0f) }
//...
.graphicsLayer {
//...
shader.setFloatUniform("time", time)
//...
在着色器代码中,我们必须添加时间统一函数:
glsl
uniform float time;
并添加虚拟圆:
glsl
float getCircle(vec2 p, vec2 pivot) {
return step(length(pivot - p), 0.1);
}
同时将虚拟圆添加到最终输出中:
glsl
vec4 main(float2 fragCoord) {
//...
float circleHorizontalPosition = sin(0.5*time)*2.;
float helperCicrle = getCircle(uv, vec2(circleHorizontalPosition, 0.));
final = mix(final, helperCicrle*vec4(1.,0.,0.,1.), helperCicrle);
return vec4(final);
}
这样,我们应该看到一个红色的参考点,我们将以此为基础来扭曲按钮。

现在,让我们尝试计算控制点对按钮的影响,使用与计算元球完全相同的方法:
glsl
float getInfluence(vec2 uv, vec2 controlPoint) {
float dist = length(controlPoint-uv);
return 1./dist;
}
vec4 main(float2 fragCoord) {
float2 uv = NormalizeCoordinates(fragCoord, resolution);
float circleHorizontalPosition = sin(0.2*time)*2.;
vec2 controlPointPos = vec2(circleHorizontalPosition, 0.);
float helperCicrle = getCircle(uv, controlPointPos);
float influence = getInfluence(uv, controlPointPos);
uv *= 1.-influence; // why here is 1 - influence was explained in Deform the Canvas tutorial
vec4 final = GetImageTexture(uv, vec2(0.5), resolution);
final = mix(final,helperCicrle*vec4(1.,0.,0.,1.),helperCicrle);
return vec4(final);
}

结果很搞笑,但不是我们的预期。内部的瑕疵("洞"效果)可以通过将影响值限制在 0 到 1 之间轻松修复。然而,这仍然不是我们想要的结果------我们得到的是控制点周围的区域膨胀了,而我们需要的效果几乎是相反的。那么,我们该如何实现呢?
答案很简单,也有点意思------虽然我花了一些时间才明白。这个想法是将坐标中心到控制点的距离与两个距离之和进行比较:从中心到当前点 (uv) 的距离,以及从当前点到控制点的距离。下图是按钮内部一个随机点的示意图。这个距离总是大于直接到中心的距离,而这个技巧就是让我们实现元球效果的关键!

所以,为了将我们现在的效果变成几乎完整的效果(除了强度设置和其他一些小的调整),我们实际上只需要将中心添加到距离计算中------就这样!
glsl
float getInfluence(vec2 uv, vec2 controlPoint) {
// float dist = length(controlPoint-uv); - was like that
float dist = length(controlPoint-uv) + length(uv); //added length(uv);
return 1./dist;
}

现在可以添加"mass"或除以距离的平方来加快淡出速度。但这些都是完善的细节,并非核心效果逻辑的一部分。我强烈建议你自己尝试一下 getInfluence 方法。
最后一步是传递另一个组件的坐标,而不是使用着色器内的控制点。听起来很简单,但这里有一些需要讨论的地方。对我来说,最难的部分是获取与着色器本身位于同一"系统"中的坐标。虽然控制点位于着色器内部,但我们使用 uv 创建了它,而 uv 已经根据视图的大小进行了归一化。但是,现在我们要获取相对于父容器的位置。那么,我们如何将所有这些结合起来呢?
首先,让我们在容器中添加第二个按钮,并将所有必要的参数传递给着色器。之后,我们将深入着色器逻辑的核心------在我看来,这是最有趣的部分。
kotlin
@Composable
fun TestShaderScreen(paddingValues: PaddingValues) {
val shader = remember { RuntimeShader(metaballShader) }
val parentBoxSize = remember { mutableStateOf(IntSize.Zero) }
val firstButtonPosition = remember { mutableStateOf(Offset.Zero) }
val secondButtonPosition = remember { mutableStateOf(Offset.Zero) }
Box(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize()
.background(Color(0xFF171717))
.onSizeChanged {
parentBoxSize.value = it
},
contentAlignment = Alignment.Center
) {
ShadedButton(
modifier = Modifier
.padding(start = 70.dp)
.size(50.dp)
.onGloballyPositioned {
firstButtonPosition.value = it.positionInParent() +
Offset(
x = it.size.width * 0.5f,
y = it.size.height * 0.5f
)
},
icon = Icons.Filled.Favorite,
shader = shader,
parentBoxSize = parentBoxSize.value,
otherViewPosition = secondButtonPosition.value,
myPosition = firstButtonPosition.value,
)
ShadedButton(
modifier = Modifier
.padding(end = 70.dp)
.size(50.dp)
.onGloballyPositioned {
secondButtonPosition.value = it.positionInParent() +
Offset(
x = it.size.width * 0.5f,
y = it.size.height * 0.5f
)
},
icon = Icons.Filled.Star,
shader = shader,
parentBoxSize = parentBoxSize.value,
otherViewPosition = firstButtonPosition.value,
myPosition = secondButtonPosition.value,
)
}
}
@Composable
fun ShadedButton(
modifier: Modifier = Modifier,
icon: ImageVector,
shader: RuntimeShader,
parentBoxSize: IntSize,
myPosition: Offset,
otherViewPosition: Offset,) {
var boxSize by remember { mutableStateOf(IntSize.Zero) }
Box(
contentAlignment = Alignment.Center,
modifier = modifier
) {
Box(
modifier = Modifier
.fillMaxSize()
.onSizeChanged {
boxSize = it
}
.graphicsLayer {
shader.setFloatUniform(
"resolution",
boxSize.width.toFloat(),
boxSize.width.toFloat()
)
shader.setFloatUniform(
"parentBoxSize",
parentBoxSize.width.toFloat(),
parentBoxSize.height.toFloat()
)
shader.setFloatUniform(
"otherViewPosition",
otherViewPosition.x,
otherViewPosition.y
)
shader.setFloatUniform(
"positionInParent",
myPosition.x,
myPosition.y
)
this.renderEffect = RenderEffect
.createRuntimeShaderEffect(shader, "image")
.asComposeRenderEffect()
}
) {
Box(
modifier = Modifier
.fillMaxSize()
.clip(RoundedCornerShape(10.dp))
.background(color = Color(0xFFF6F6F6))
)
}
Icon(
imageVector = icon,
contentDescription = "Expand Menu",
tint = Color.Black
)
}
}
基本上,我们添加的只是传递父容器的大小和相邻视图的中心坐标。请记住,由于 Compose 回调返回的位置是左上角,因此我们需要在传递之前对其进行一些调整。另外,请记住,第一个组件应该在 otherViewPosition 参数中接收第二个组件,第二个组件也应该接收第一个组件。最重要的是不要混淆它们 :)
让我们继续讨论着色器。我们将从添加必要的 uniform 开始。
glsl
uniform float2 otherViewPosition;
uniform float2 parentBoxSize;
uniform float2 positionInParent;
现在到了有趣的部分:之前的控制点现在需要根据两个参数来计算------父级的大小以及第二个视图相对于父级的坐标。
首先,我们来获取容器大小与按钮大小的比率。
glsl
float parentRatio = parentBoxSize.x / resolution.x;
现在我们只需要稍微修改一下 getInfluence 方法,就能将所有内容整合到一个坐标系中:
glsl
float getInfluence(vec2 uv, float ratio) {
float2 posInParentNormalized = (positionInParent/parentBoxSize) - 0.5;
float2 controlPoint = otherViewPosition / parentBoxSize - 0.5;
controlPoint.x = (controlPoint.x-posInParentNormalized.x) * ratio;
float dist = max(1., length(controlPoint-uv) + length(uv));
float influence = smoothstep(0.,1., 1./pow(dist,2.));
return influence;
}
听起来很简单,但实际上,过程中出现了一些不太明显的计算。而且这些计算并非一眼就能轻易掌握。所以我尽量把它解释得清晰易懂。所以,从整体上看,我们得到的是这样的:

好的,我们有父容器、它的大小、我们视图相对于父容器的位置,以及相邻视图(我们之前示例中的控制点)的位置。我们的任务是将这个控制点放到我们的UV坐标系中。

我尝试自上而下地解释了整个过程。现在让我们再看一下代码,并逐步讲解一下。
glsl
float2 posInParentNormalized = (positionInParent/parentBoxSize) - 0.5;
float2 controlPoint = otherViewPosition / parentBoxSize - 0.5;
float parentRatio = parentBoxSize.x / resolution.x;
controlPoint.x = (controlPoint.x-posInParentNormalized.x) * parentRatio;
假设父容器的宽度为 500。我们在其中的位置为 100,相邻视图的位置为 400。首先,我们获取两个视图相对于父容器的标准化坐标(偏移 -0.5)。在本例中,我们的位置为 -0.3,相邻视图的位置为 0.2。因此,它们之间的距离为 0.5------即父容器宽度的一半。但请记住,我们位于附加到自身视图的着色器内部,因此必须将这个值乘以父容器大小与我们视图大小的比值。这是关键的一步:我们现在得到的不是 0.5,而是另一个值,但它位于我们视图的坐标系中。由此,我们可以计算距离,并执行之前对虚拟控制点执行的所有操作!
_这里我将所有内容简化为水平坐标,但同样的逻辑也适用于垂直坐标。我只是不想让本来就很复杂的解释更加难以理解。
最终,我们得到了:

最后一步是用数组替换这两个视图,这样我们就可以添加第三个、第四个甚至第五个按钮。
在着色器中,不能使用动态数组,所以我们需要设置一个上限------我选择了 10 个元素。我们还需要一个单独的计数器来记录实际有多少个元素。由于数组不能留空,在 Compose 中,我们会自动用零位置填充未使用的槽位,但计数器会确保这不会影响最终结果。
因此,在着色器中,uniform 变量看起来如下:
glsl
uniform int count;
uniform float2 positions[10];
uniform float2 parentResolution;
uniform float2 positionInParent;
如果多个相邻元素同时影响视图,getInfluence 方法将循环遍历所有元素并累积影响。
glsl
float getInfluence(float2 uv, float ratio) {
float influence = 0.0;
for (int i = 0; i < 10; i++) {
float posInParentNormalized = (positionInParent/parentBoxSize) - 0.5;
float2 controlPoint = positions[i] / parentBoxSize - 0.5;
controlPoint.x = (controlPoint.x-posInParentNormalized) * r;
float dist = max(1.,length(controlPoint-uv) + length(uv));
float rawScale = 1./pow(dist,2.);
influence += smoothstep(0., 1., rawScale);
if(i==count-1) break;
}
return clamp(influence,0.,1.);
}
在 Compose 中,我们需要添加一个列表并仔细传递所有值。你也可以添加动画和其他效果,但这会使 Compose 代码过载,在本课中更难理解。本教程已经相当丰富,所以我只演示如何将值传递到数组中------其余的实验留给你自己 :) 记住,你可以随时查看我的代码库以获取完整版本。
下面是我编写的一个扩展方法,用于方便地向着色器添加列表。
kotlin
fun RuntimeShader.setVec2ArrayUniform(
name: String,
values: List<Pair<Float, Float>>,
maxSize: Int = 10) {
require(values.size <= maxSize) {
"Too many elements for uniform '$name'. Maximum allowed is $maxSize, but got ${values.size}"
}
val padded = values + List(maxSize - values.size) { 0f to 0f }
val floatArray = padded.flatMap { listOf(it.first, it.second) }.toFloatArray()
this.setFloatUniform(name, floatArray)
}
用法:
kotlin
shader.setVec2ArrayUniform(name, values)
现在你可以添加不同的元素,并观察它们在动画过程中平滑地合并或分离:

感谢你的阅读!如果你觉得我的实验有趣且我的解释对你有帮助,欢迎加入我的Telegram频道或在Twitter (X)上关注我。这个项目只是我的一个爱好,说实话,我的动力很大程度上取决于收到的反馈------所以我非常高兴在频道里见到你。如果你愿意,请在你的社交媒体上分享这篇文章,我将不胜感激。祝你撸码愉快!
欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!
保护原创,请勿转载!