Unity 融球(Metaball)

2026-01-24 23:53:20 · 3 minute read

融球(Metaball)是一种非常酷的图形学效果,它能让多个独立的物体像黏糊糊的液体或水银一样,在靠近时自然地融合成一个整体。

核心概念 直观比喻 在融球效果中的作用
势能函数 (Energy Field) 每个球体像一个小热源,向周围散发“热量” 定义每个球体对空间中点的影响力大小和范围
叠加原理 (Superposition) 多个热源靠得近时,它们的热量会叠加,使该区域更热。 当球体靠近时,它们的能量场相加,形成一个连续的、更强的能量区域。
阈值与等势面 (Threshold & Isosurface) 设定一个温度阈值,只显示温度高于阈值的区域。 通过设定一个临界值,从叠加后的总能量场中“切割”出平滑的融合形状。

融球如何工作

理解了上述核心概念后,我们来深入看看它的具体实现步骤。

1.定义能量场

每个融球都携带一个能量场,通常使用一个随距离增加而衰减的函数来定义。一个经典的二维势能函数形式是:能量 = 半径² / 距离²。这意味着:

2.计算叠加效果

对于屏幕上的每一个像素点,程序会计算所有融球在该点产生的能量值,并将它们相加,得到一个总能量值。这就是叠加原理的应用。如果一个点同时处于两个融球的影响范围内,它的总能量就会显著高于只受一个融球影响的情况。

3.通过阈值“雕刻”形状

这是最关键的一步。我们设定一个能量阈值,然后使用一个平滑函数(如 smoothstep)来判断每个像素点的总能量是否超过这个阈值。

实现 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 如下:

已复制