融球(Metaball)是一种非常酷的图形学效果,它能让多个独立的物体像黏糊糊的液体或水银一样,在靠近时自然地融合成一个整体。
| 核心概念 | 直观比喻 | 在融球效果中的作用 |
|---|---|---|
| 势能函数 (Energy Field) | 每个球体像一个小热源,向周围散发“热量” | 定义每个球体对空间中点的影响力大小和范围 |
| 叠加原理 (Superposition) | 多个热源靠得近时,它们的热量会叠加,使该区域更热。 | 当球体靠近时,它们的能量场相加,形成一个连续的、更强的能量区域。 |
| 阈值与等势面 (Threshold & Isosurface) | 设定一个温度阈值,只显示温度高于阈值的区域。 | 通过设定一个临界值,从叠加后的总能量场中“切割”出平滑的融合形状。 |
融球如何工作
理解了上述核心概念后,我们来深入看看它的具体实现步骤。
1.定义能量场
每个融球都携带一个能量场,通常使用一个随距离增加而衰减的函数来定义。一个经典的二维势能函数形式是:能量 = 半径² / 距离²。这意味着:
- 距离球心越近,能量越强。
- 球体的半径越大,其能量场的强度和范围也越大。
2.计算叠加效果
对于屏幕上的每一个像素点,程序会计算所有融球在该点产生的能量值,并将它们相加,得到一个总能量值。这就是叠加原理的应用。如果一个点同时处于两个融球的影响范围内,它的总能量就会显著高于只受一个融球影响的情况。
3.通过阈值“雕刻”形状
这是最关键的一步。我们设定一个能量阈值,然后使用一个平滑函数(如 smoothstep)来判断每个像素点的总能量是否超过这个阈值。
- 如果总能量远高于阈值,这个点就完全属于融合体内部(通常设置为不透明)。
- 如果总能量远低于阈值,这个点就完全在融合体外部(完全透明)。
- 如果总能量在阈值附近,这个点就位于融合体的边缘。平滑函数会生成一个介于0和1之间的渐变透明度,从而创造出非常自然平滑的边缘过渡,这是融球效果看起来不生硬的关键。
实现 Unity 融球
首先创建一个 MetaBall.cs,作为融球对象:
using UnityEngine;
public class MetaBall : MonoBehaviour
{
public MetaBallContainer Container;
[Header("球体设置")] public float radius = 0.5f;
public Color ballColor = Color.white;
void OnEnable()
{
// 注册自己到激活列表
Container.Add(this);
}
void OnDisable()
{
Container.Remove(this);
}
}
接着我们创建融球对象的容器,用于更新 GPU 缓冲区,同时,可以跟不同的容器相隔离:MetaBallContainer.cs
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using UnityEngine;
[CreateAssetMenu(fileName = "MetaBalls", menuName = "MetaBalls")]
public class MetaBallContainer : ScriptableObject
{
// 静态列表,记录场景中所有激活的融球
private readonly List<MetaBall> activeBalls = new List<MetaBall>();
private ComputeBuffer ballBuffer; // GPU数据缓冲区
// 与Shader中属性对应的ID
private static readonly int BallBufferID = Shader.PropertyToID("_BallBuffer");
private static readonly int BallCountID = Shader.PropertyToID("_BallCount");
public Material targetMaterial; // 应用融球 Shader 的材质
private void OnDestroy()
{
activeBalls.Clear();
Dispose();
}
public void Add(MetaBall ball)
{
if (!activeBalls.Contains(ball))
{
activeBalls.Add(ball);
UpdateComputeBuffer();
}
}
public void Remove(MetaBall ball)
{
if (activeBalls.Remove(ball))
UpdateComputeBuffer();
}
public void Dispose()
{
// 释放现有缓冲区
if (ballBuffer != null)
{
ballBuffer.Release();
ballBuffer = null;
}
}
private void UpdateComputeBuffer()
{
Dispose();
// 如果有激活的球体,创建新的缓冲区
if (activeBalls.Count > 0)
{
var stride = Marshal.SizeOf(typeof(BallData));
ballBuffer = new ComputeBuffer(activeBalls.Count, stride);
// 将缓冲区分配给所有球体使用的材质
targetMaterial.SetBuffer(BallBufferID, ballBuffer);
targetMaterial.SetInt(BallCountID, activeBalls.Count);
}
}
public void LateUpdate()
{
// 每一帧将所有球体的最新数据(位置、半径、颜色)传递给GPU
if (ballBuffer == null || targetMaterial == null) return;
var balls = activeBalls.FindAll(a => a);
var dataArray = new BallData[balls.Count];
for (var i = 0; i < balls.Count; i++)
{
var ball = balls[i];
dataArray[i] = new BallData
{
position = ball.transform.position,
radius = ball.radius * ball.transform.lossyScale.x, // 考虑缩放
color = ball.ballColor
};
}
ballBuffer.SetData(dataArray);
}
// 定义传递给Shader的球体数据结构(内存布局需连续)
[Serializable]
[StructLayout(LayoutKind.Sequential)]
public struct BallData
{
public Vector2 position;
public float radius;
public Vector4 color; // 使用Vector4传输颜色
}
}
由于 MetaBallContainer 是ScriptableObject,因此需要一个 Updater 来调用其中的LateUpdate方法:
using UnityEngine;
public class MetaBallsUpdater : MonoBehaviour
{
[SerializeField] private MetaBallContainer[] metaBallContainers;
private void LateUpdate()
{
foreach (var metaBalls in metaBallContainers)
if (metaBalls)
metaBalls.LateUpdate();
}
}
最后是核心 shader 了:
// 计算单个球体在某点的能量值
float calculateMetaball(float2 p, float2 center, float radius)
{
float d = distance(p, center);
return pow(radius / max(d, 0.0001), _Stickiness);
}
这里我们采用的能量计算方式并非经典的能量 = 半径² / 距离²,而是采用了改进公式:pow(半径 / 距离, 粘性系数),这两者的区别如下:
| 特性 | 经典公式: 半径² / 距离² | 改进公式: pow(半径 / 距离, 粘性系数) |
|---|---|---|
| 核心数学形式 | 平方反比关系 | 基于比率的幂次关系 |
| 距离敏感性 | 高(距离减小,能量急剧增加) | 可通过 _Stickiness 参数调节 |
| 可控性 | 固定,调整难度大 | 高,通过 _Stickiness 灵活控制衰减速度 |
| 计算开销 | 相对较低(乘法和除法) | 稍高(引入了 pow 运算) |
| 主要优势 | 概念经典,实现直观 | 艺术调控性强,能实现更自然的融合 |
从表格可以看出,改进公式的核心优势在于引入了 _Stickiness 这个可控参数。这使得开发者可以像调节旋钮一样,轻松控制能量场的衰减速度。 |
fixed4 frag(v2f i) : SV_Target
{
float2 worldPos2D = i.worldPos.xy;
float sum = 0;
// 用于颜色混合的变量
float4 finalColor = float4(0, 0, 0, 0);
float totalWeight = 0;
// 循环所有球体,累加能量
for (int idx = 0; idx < _BallCount; idx++)
{
BallData ball = _BallBuffer[idx];
float fieldStrength = calculateMetaball(worldPos2D, ball.position, ball.radius);
sum += fieldStrength;
// 核心颜色混合逻辑:根据每个球的能量场强加权累加颜色[1](@ref)
finalColor += ball.color * fieldStrength * ball.color.a;
totalWeight += fieldStrength;
}
// 标准化颜色:将加权累加的颜色除以总权重,得到平均颜色[1](@ref)
if (totalWeight > 0)
{
finalColor /= totalWeight;
}
// 使用总能量场强和阈值生成平滑的透明通道(融球形状)
float smoothedAlpha = smoothstep(_Threshold - _Smoothness, _Threshold + _Smoothness, sum);
// 最终输出:RGB来自混合颜色,Alpha决定融球形状
return fixed4(finalColor.rgb, smoothedAlpha);
}
高级技巧与优化
掌握了基本原理,你还可以通过一些技巧让效果更出色或性能更高。
颜色混合:要实现融合区域的颜色过渡,可以在计算总能量的同时,根据每个球体能量的权重来混合它们的颜色。能量贡献越大的球体,其颜色在最终结果中的占比也越高。
性能优化:直接对每个像素计算所有球体的影响在球体很多时开销巨大。常见的优化方法包括:
空间划分:只计算当前位置附近一定范围内的球体影响,忽略过远的球体。
使用近似算法:有时可以用更简单的数学函数来模拟融合效果,以提升计算速度。
实际应用
融球效果绝不仅仅是视觉玩具,它有着广泛的应用:
影视特效与动画:用于模拟水流、黏液、魔法特效等有机流体的融合与分离。
数据可视化:将抽象的数据点用融球的形式表现,其融合程度可以反映数据之间的关联性。
产品设计与UI动效:在一些现代应用和网站中,融球原理常被用于创造吸引人且流畅的按钮悬停、加载动画等交互效果。
项目使用 Unity 版本为 2020.3.49f1,源码 package 如下:
- 通过网盘分享的文件:Metaball-Unity-2020.3.49f1.unitypackage
- 链接: https://pan.baidu.com/s/1w2mmNRfEyMjR5B01wMWyiA?pwd=w1a4