Skip to content

单材质实现

融球效果的核心原理,是隐式曲面 (Implicit Surface),这里的球体不是由顶点定义的,而是由一个场函数 f(x, y, z) 定义的。所有满足 f(x,y,z) = c 的点构成了表面,不太理解没关系,这里先使用一个单独的材质,简单模拟融球效果,建立对 SDF 和 Ray Marching 的直观概念:

材质设置为 Translucent,其中的两个自定义节点的完整代码见附录:代码附录

其中的 Ray March Cube Setup 为后续的光线步进算法准备初始参数,对于这样一个简单的模拟,我们实际上将后面的 Ray Marching 算法限制在一个立方体的范围当中,为此这个节点计算摄像机光线与一个单位立方体的交点,并输出入口点坐标和光线在立方体内的穿行距离。这主要是为了性能考虑,不然这一步可以省略,默认从摄像机开始 Ray Marching,到最大距离停止即可

(Ray March Cube Setup 中还有一个 Slab Test 算法,这个算法专门计算光线和立方体(xyz轴对齐)的交点,不太清楚的可以搜索一下,有很多教程)

随后的 SimpleMetaBall 就是融球渲染核心的 Ray Marching 算法:

float Sphere(float3 RayPos, float3 center, float Radius) 计算一个点的 SDF,即该点到球体表面的最短有向距离,distance(RayPos, center) 计算点到球心的距离减去半径 Radius 后:如果结果 > 0,点在球外。如果结果 < 0,点在球内。如果结果 = 0,点在球表面。后续这个算法会移动至GPU中运算,并将结果保存在 RT 纹理当中

float4 hash4( float n ) 是一个简单的伪随机数生成器,就是生成n个球体的参数,包括随机中心点和对应的随机半径。这里只是临时使用,后面会用Niagara生成这些球体(粒子)

float metaball(float3 RayPos, ...) 为核心函数。计算 RayPos 这一空间点到整个Metaball聚合体的最终距离:

  1. for(int i=0; i<5; i++): 循环5次,这个随意调整,代表这个Metaball由5个小球组成
  2. rand = hash4(i * 1.0): 为每个球生成一个随机参数
  3. d = Sphere(...): 计算当前点到这个移动中的、大小随机的球的距离。球心 center + AnimationRange*float3(...) 基于时间 time 和随机数做周期性运动。AnimationRange 控制运动幅度。
  4. accm += exp(-kd): 是关键步骤,它将距离d转换为一个“场能量”或“影响力”。k 是一个平滑/融合系数。k越大,球体之间的融合边界越尖锐;k越小,融合得越平滑、越“黏糊”。exp(-kd) 的特性是:当d很小(靠近球体)时,值接近1;当d很大时,值迅速衰减到0
  5. return -log(accm): 将所有球的“能量”总和 accm 转换回一个统一的距离值。这个公式是多种SDF平滑融合(Smooth Union)方法中的一种,也可以选择其他公式

float3 calcNormal(float3 RayPos, ...) 在给定点 RayPos 计算Metaball表面的法线向量。SDF的梯度(Gradient)就是其表面的法线。通过在X, Y, Z三个轴上对 RayPos 进行微小的位移(+eps 和 -eps),然后计算 metaball 函数值的变化率,来近似求解梯度

后续的主循环中,使用了最简单的 Ray Marching 方法,查询点每次前进一个固定值,计算查询点与整个Metaball聚合体(即5个球体)的“场能量”或“影响力”,当其达到阈值时,判定为表面,在输出的第4个通道记录为1,即Ret.a = 1; ,并计算法线存储在前三个通道。将 Ret.a 连接到透明度通道看看结果:

有了融球表面和其法线后,就可以随意调整材质的其他部分了,例如:

这里需要设置材质属性 “Refraction-> Refraction Method-> Pixel Normal Offet”,效果如下:

整体架构

上面的材质简单展示了融球效果的原理,接下来就是本章教程的重点,如何将其扩展至一个完整的系统,合理的管理数据的流动,即 Niagara/CPU Sim -> Structured Buffer -> Compute Shader ->Render Target -> “Raymarching” Material -> Final Image,这是现代实时渲染中实现很多效果(如融球、云朵、力场)的常用方法论:

  1. 数据源: 融球核心。每个核心本质上是一个点,包含位置(Vector3)、半径(float)、强度(float)等属性。是整个系统的输入
  2. 数据总线: StructuredBuffer。这是一个通用的GPU缓冲区,可以存储任意自定义结构的数据。它连接数据源和SDF计算单元
  3. 计算单元: Compute Shader。一个GPU程序,其任务是:
    • 读取 StructuredBuffer 中的所有融球数据
    • 在三维空间中划分出大量计算点(体素)
    • 对每个体素,计算它到所有融球表面的综合有向距离(SDF)
    • 将计算出的SDF值写入一个3D纹理(在UE中是 Volume Texture,即 Render Target Volume)
  4. 数据存储: Render Target (Volume Texture)。一个三维的渲染目标,每个“像素”(体素)存储一个float值,即该空间点的SDF值。正值代表在物体外,负值在物体内,0在表面
  5. 渲染单元: Raymarching Material。一个应用在某个几何体(通常是一个立方体盒子,包围整个效果范围)上的材质。其工作原理是:
    • 从摄像机向像素发射光线
    • 光线进入立方体后,开始“步进(marching)”
    • 在每一步,查询 SDF体纹理,获取当前位置的距离值
    • 根据这个距离值,可以安全地前进一大步 (Sphere Tracing优化)
    • 当距离值接近于0时,认为光线击中了融球表面
    • 通过对周围SDF值采样计算梯度,得到表面法线,然后进行光照计算,输出最终颜色

接下来一个问题是,粒子源可以来自不同的地方,CPU或者本身就在GPU(Niagara),我们先分开讨论,最后的方案会采用接口抽象,让后面的系统可以兼容这两种粒子源。首先是如果当粒子逻辑在CPU端定义(例如,在一个AActor或UActorComponent的Tick中更新)时:

  1. 管理器: 创建一个C++类,例如 AMetaballManager_CPU (Actor)
  2. 数据持有: 在这个管理器中,持有一个 TArray<FMetaballData>
  3. 模拟逻辑: 在Tick()函数中,更新这个TArray里的数据。例如,像上面的例子中一样,让每个球做简单的正弦运动
  4. 资源创建: 在 BeginPlay() 中,动态创建所需的GPU资源:
    • UVolumeTexture (或 UTextureRenderTargetVolume): 用于存储SDF
    • FBufferRHIRef: 用于传输数据
  5. 数据传输 (每帧): 将CPU的TArray数据拷贝到GPU的StructuredBuffer中
  6. 调度CS: 数据传输完成后,调度(Dispatch) SDF Compute Shader。CS会读取这个刚更新的Buffer,计算SDF并写入RT
  7. 材质连接: 将生成的SDF体纹理和相关参数(如包围盒大小、位置等)传递给Raymarching材质

逻辑简单直观,所有模拟都在CPU端,易于调试。适合融球数量不多(几百个以内)或模拟逻辑非常复杂的场景。但每帧都需要进行CPU到GPU的数据拷贝,当融球数量巨大时,这会成为严重的性能瓶颈

其次考虑当粒子使用Niagara模拟时:

  1. 数据接口: Niagara提供了一个强大的工具:Niagara Data Interface RW Structured Buffer。这是一个可以在Niagara和外部系统(如C++或蓝图)之间共享的数据接口
  2. 管理器: 仍然需要一个管理器类 AMetaballManager_Niagara,但它的职责变了。
  3. 资源创建与连接:
    • 在C++或蓝图中,创建一个FBufferRHIRef资产(或在运行时创建)
    • 创建一个Niagara系统
    • 在Niagara系统中,添加 Niagara Data Interface RW Structured Buffer
    • 在Niagara系统中,创建一个UObject类型的用户参数(如 MetaballOutputBuffer)
    • 在管理器的BeginPlay()中,将C++创建的FBufferRHIRef对象设置给Niagara组件的MetaballOutputBuffer用户参数
  4. Niagara内部逻辑:
    • 在Niagara发射器的Particle Update阶段,添加一个自定义模块(Scratch Pad Module)
    • 在这个模块中,使用Niagara DI Structured Buffer提供的函数,将每个粒子的数据(如Particles.Position, Particles.Radius)写入到Structured Buffer的对应索引位置。ParticleID或Exec Index可以作为索引
  5. 调度CS: 管理器现在不需要做数据拷贝了。它只需要在Niagara系统更新之后(可以设置Tick依赖),直接调度SDF Compute Shader即可。CS读取的Buffer会是Niagara负责写入

数据从头到尾都在GPU上,没有CPU-GPU的传输开销。可以轻松模拟数万甚至数十万个融球。但如果模拟逻辑依赖于复杂的全局状态或CPU端的游戏逻辑,实现起来会更麻烦

上面只是举例说明一下,后面的具体实现会有些许不同,我们将数据源抽象出来,可以自由选择粒子源,保持系统的高度可扩展性

核心模块

准备

首先修改项目名称.Build.cs,引入一些额外的模块:

PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput" , "RenderCore", "RHI" });	// RenderCore和RHI

修改项目名称.cpp,添加着色器文件路径,也可以使用上一篇教程 虚幻引擎材质入门 最后面 在Custom节点包含usf文件 的方法,但这两种注入的时机不同,可能会有影响,但不太清楚:

cpp
#include "ExMaterial.h"
#include "Modules/ModuleManager.h"
#include "ShaderCore.h" // 需要包含这个头文件

class FExMaterialModule : public FDefaultGameModuleImpl
{
public:
	// 重写 StartupModule
	virtual void StartupModule() override
	{
		// 获取项目的 Shaders 目录的物理路径
		FString ShaderDirectory = FPaths::Combine(FPaths::ProjectDir(), TEXT("Shaders"));
        
		// 将虚拟路径 /Project/Shaders/ 映射到物理路径
		AddShaderSourceDirectoryMapping(TEXT("/Project/Shaders"), ShaderDirectory);
	}

	// 重写 ShutdownModule 来取消映射,虽然不是严格必需的
	virtual void ShutdownModule() override
	{
		// 清理
		ResetAllShaderSourceDirectoryMappings();
	}
};

IMPLEMENT_PRIMARY_GAME_MODULE( FExMaterialModule, ExMaterial, "ExMaterial" );

处理数据源

首先在MetaballData.h中定义一下单个球体的数据结构:

cpp
#pragma once
#include "CoreMinimal.h"

// 融球核心数据结构
// 使用 FVector3f 因为渲染线程处理浮点数更高效,且避免了FVector双精度带来的对齐问题
// alignas(16) 确保与GPU内存对齐
struct alignas(16) FMetaballData
{
	FVector3f Position;
	float Radius;
};

随后创建MetaballDataSource.h,这是一个抽象的融球数据源:

cpp
#pragma once

#include "CoreMinimal.h"
#include "UObject/Object.h"
#include "RHI.h" // 包含RHI定义
#include "MetaballDataSource.generated.h"

UCLASS(Abstract, EditInlineNew) // Abstract: 不能直接实例化, EditInlineNew: 可以在详情面板中创建实例
class EXMATERIAL_API UMetaballDataSource : public UObject
{
	GENERATED_BODY()
public:
	// 初始化数据源,在Manager的BeginPlay中调用
	virtual void Initialize(UWorld* World) {}
	
	// 每帧更新,在Manager的Tick中调用
	virtual void Tick(float DeltaTime) {}

	// 获取数据最终写入的Structured Buffer
	virtual FBufferRHIRef GetMetaballBufferRHI() const { return nullptr; }

	// 获取融球数量
	virtual int32 GetMetaballCount() const { return 0; }

	// 清理资源
	virtual void BeginDestroy() override
	{
		// 确保GPU资源被正确释放
		StructuredBufferRHI.SafeRelease();
		Super::BeginDestroy();
	}

protected:
	// 子类将使用这个成员来持有它们的GPU缓冲
	FBufferRHIRef StructuredBufferRHI;
};

接下来,就可以从上面的抽象数据源中派生出 UCPU_MetaballDataSource ,在这里处理CPU粒子源的逻辑更新、缓冲区的创建与更新,派生出UGPU_MetaballDataSource,处理与Niagara的缓冲区对接,但这样篇幅就过长了,后面只用CPU作为粒子源的情况具体说明,因此进一步将缓冲区的创建与更新这两个涉及渲染指令的操作放在后面的类中,这样资源同步逻辑会简单一些,UCPU_MetaballDataSource 只负责处理CPU粒子源的逻辑更新,因此没什么特殊的,查看代码中的注释即可:

cpp
#pragma once

#include "CoreMinimal.h"
#include "MetaballDataSource.h"
#include "MetaballData.h"
#include "CPU_MetaballDataSource.generated.h"

// 这个结构体专门用于在编辑器中进行编辑
USTRUCT(BlueprintType)
struct FEditableMetaball
{
    GENERATED_BODY()
    
    UPROPERTY(EditAnywhere, Category="Metaball", meta=(MakeEditWidget="true"))
    FVector Position = FVector::ZeroVector;

    UPROPERTY(EditAnywhere, Category="Metaball", meta=(ClampMin="0.1"))
    float Radius = 30.0f;
};

UCLASS()
class EXMATERIAL_API UCPU_MetaballDataSource : public UMetaballDataSource
{
    GENERATED_BODY()
public:
    UPROPERTY(EditAnywhere, Category="Metaballs")
    TArray<FEditableMetaball> EditableBalls;

    UPROPERTY(EditAnywhere, Category="CPU Source")
    bool bAnimate  = false;

    UPROPERTY(EditAnywhere, Category="CPU Source")
    float SimulationSpeed = 0.2f;
    
    UPROPERTY(EditAnywhere, Category="CPU Source")
    float SimulationScale = 50.0f;

    virtual void Initialize(UWorld* World) override;
    virtual void Tick(float DeltaTime) override;

    virtual int32 GetMetaballCount() const override { return CpuMetaballData.Num(); }

    // 公开接口,让Manager可以获取到最新一帧的模拟数据
    const TArray<FMetaballData>& GetMetaballData() const { return CpuMetaballData; }

private:
    // 将编辑器数据转换为渲染所需的数据
    void UpdateRenderData();

    TArray<FMetaballData> CpuMetaballData;

    // 用于动画的额外数据
    TArray<FVector> InitialPositions;

    float Time = 0.0f;
};
cpp
#include "CPU_MetaballDataSource.h"
#include "RHICommandList.h"

void UCPU_MetaballDataSource::Initialize(UWorld* World)
{
    Super::Initialize(World);
    
    // 存储初始位置以备动画使用
    InitialPositions.SetNum(EditableBalls.Num());
    for(int32 i = 0; i < EditableBalls.Num(); ++i)
    {
       InitialPositions[i] = EditableBalls[i].Position;
    }

    // 首次更新渲染数据
    UpdateRenderData();
}

void UCPU_MetaballDataSource::Tick(float DeltaTime)
{
    // 检查编辑器中的数组数量是否发生变化(例如,用户在运行时添加/删除了元素)
    // 如果变了,需要重新初始化数据
    if (EditableBalls.Num() != CpuMetaballData.Num())
    {
       Initialize(GetWorld());
       return; 
    }
    
    UpdateRenderData();
    
    // 如果启用了动画
    if (bAnimate)
    {
       Time += DeltaTime * SimulationSpeed;

       for (int32 i = 0; i < CpuMetaballData.Num(); ++i)
       {
          // 基于初始位置进行动画,而不是覆盖它
          float t = Time + i * 0.5f;
          FVector AnimationOffset = FVector(
             FMath::Sin(t) * SimulationScale,
             FMath::Cos(t * 0.7f) * SimulationScale,
             FMath::Sin(t * 1.2f) * SimulationScale * 0.5f
          );
          
          CpuMetaballData[i].Position = static_cast<FVector3f>(InitialPositions[i] + AnimationOffset);
       }
    }
}

void UCPU_MetaballDataSource::UpdateRenderData()
{
    // 确保渲染数据数组和编辑器数据数组大小一致
    if (CpuMetaballData.Num() != EditableBalls.Num())
    {
       CpuMetaballData.SetNum(EditableBalls.Num());
    }

    // 将 FEditableMetaball (编辑器数据) 转换为 FMetaballData (渲染数据)
    for (int32 i = 0; i < EditableBalls.Num(); ++i)
    {
       // 如果不播放动画,就直接从 EditableBalls 更新位置
       // 这样拖拽Gizmo时就能实时看到效果
       if (!bAnimate)
       {
          CpuMetaballData[i].Position = static_cast<FVector3f>(EditableBalls[i].Position);
       }
       
       CpuMetaballData[i].Radius = EditableBalls[i].Radius;
    }
}

绘制SDF到体纹理

创建最终的管理器类 MetaballManagger.h

cpp
#pragma once

#include "CoreMinimal.h"
#include "MetaballData.h"
#include "GameFramework/Actor.h"
#include "MetaballManager.generated.h"

class UMetaballDataSource;
class UTextureRenderTargetVolume;

UCLASS()
class EXMATERIAL_API AMetaballManager : public AActor
{
    GENERATED_BODY()

public:
    AMetaballManager();
    // 添加一个静态网格组件来显示我们的效果
    UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category="Metaball")
    TObjectPtr<UStaticMeshComponent> MeshComponent;

    // 在编辑器中指定我们的基础材质 (M_MetaballRaymarch)
    UPROPERTY(EditAnywhere, Category="Metaball")
    TObjectPtr<UMaterialInterface> BaseMaterial;

    // 核心属性:数据源。Instanced允许我们在编辑器中创建并编辑其子类的实例
    UPROPERTY(EditAnywhere, Instanced, Category="Metaball")
    TObjectPtr<UMetaballDataSource> DataSource;

    // 输出的SDF体纹理
    UPROPERTY(EditAnywhere, Category="Metaball")
    TObjectPtr<UTextureRenderTargetVolume> SDF_RenderTarget;

    // 融合程度
    UPROPERTY(EditAnywhere, Category="Metaball")
    float SmoothnessFactor = 0.5f;

    // 模拟的包围盒大小
    UPROPERTY(EditAnywhere, Category="Metaball")
    FVector BoundsSize = FVector(100.f, 100.f, 100.f);

    // SDF体纹理的分辨率
    UPROPERTY(EditAnywhere, Category="Metaball", meta=(ClampMin="16", ClampMax="256"))
    FIntVector GridDimensions = FIntVector(64, 64, 64);

protected:
    virtual void BeginPlay() override;
    virtual void EndPlay(const EEndPlayReason::Type EndPlayReason) override;

public:
    UFUNCTION(BlueprintCallable)
    void UpdateMetaballs(float DeltaTime);

    virtual void BeginDestroy() override; // 需要重写BeginDestroy来解绑委托

private:
    // 用于持有在运行时创建的动态材质实例
    UPROPERTY()
    TObjectPtr<UMaterialInstanceDynamic> DynamicMaterialInstance;
    
    FDelegateHandle PostOpaqueRenderDelegateHandle;

    // RDG Pass的入口函数,将在渲染线程中被调用
    void Render_RenderThread(FPostOpaqueRenderParameters& Parameters);

    // 双缓冲,用于从GameThread安全地传递数据到RenderThread
    // GameThread写入BackBuffer, RenderThread读取FrontBuffer
    TArray<FMetaballData> MetaballDataFrontBuffer;
    TArray<FMetaballData> MetaballDataBackBuffer;
    FCriticalSection BufferSwapCriticalSection; // 用于保护缓冲区交换操作的锁

    // 用于线程安全的数据缓存
    float CachedSmoothnessFactor_RenderThread;
    FVector3f CachedBoundsMin_RenderThread;
    FVector3f CachedBoundsSize_RenderThread;
    FIntVector CachedGridDimensions_RenderThread;
};
cpp
#include "MetaballManager.h"

#include "CPU_MetaballDataSource.h"
#include "DataDrivenShaderPlatformInfo.h"
#include "MetaballDataSource.h"
#include "Engine/TextureRenderTargetVolume.h"
#include "GlobalShader.h"
#include "MetaballData.h"
#include "ShaderParameterStruct.h"
#include "RenderGraphUtils.h"
#include "RHICommandList.h"
#include "RHIResources.h"
#include "TextureResource.h"
#include "RendererInterface.h" // 包含渲染器接口
#include "RenderGraphResources.h"

// ----------- 定义Compute Shader的C++侧绑定 -----------
class FMetaballSDF_CS : public FGlobalShader
{
public:
    DECLARE_GLOBAL_SHADER(FMetaballSDF_CS);
    SHADER_USE_PARAMETER_STRUCT(FMetaballSDF_CS, FGlobalShader);

    // 定义参数结构体,必须与HLSL中的一一对应
    BEGIN_SHADER_PARAMETER_STRUCT(FParameters, )
        // RDG: 使用FRDGBufferSRVRef和FRDGTextureUAVRef
        SHADER_PARAMETER_RDG_BUFFER_SRV(StructuredBuffer<FMetaballData>, Metaballs)
        SHADER_PARAMETER_RDG_TEXTURE_UAV(RWTexture3D<float>, SDF_Output)
    
        SHADER_PARAMETER(uint32, NumMetaballs)
        SHADER_PARAMETER(float, SmoothnessFactor)
        SHADER_PARAMETER(FVector3f, BoundsMin)
        SHADER_PARAMETER(FVector3f, BoundsSize)
        SHADER_PARAMETER(FVector3f, GridDimensions)
    END_SHADER_PARAMETER_STRUCT()

    static bool ShouldCompilePermutation(const FGlobalShaderPermutationParameters& Parameters)
    {
        return GetMaxSupportedFeatureLevel(Parameters.Platform) >= ERHIFeatureLevel::SM5;
    }
};

// 将HLSL文件与C++类关联起来
IMPLEMENT_GLOBAL_SHADER(FMetaballSDF_CS, "/Project/Shaders/MetaballSDF.usf", "MainCS", SF_Compute);

// ----------- Manager的实现 -----------
AMetaballManager::AMetaballManager()
{
    PrimaryActorTick.bCanEverTick = false;
    
    MeshComponent = CreateDefaultSubobject<UStaticMeshComponent>(TEXT("MeshComponent"));
    RootComponent = MeshComponent;
}

void AMetaballManager::BeginPlay()
{
    Super::BeginPlay();
    
    if (DataSource)
    {
        DataSource->Initialize(GetWorld());

        // 如果是CPU数据源,就拉取数据
        if (UCPU_MetaballDataSource* CpuSource = Cast<UCPU_MetaballDataSource>(DataSource))
        {
            // 锁定,将数据拷贝到后台缓冲区
            FScopeLock Lock(&BufferSwapCriticalSection);
            MetaballDataBackBuffer = CpuSource->GetMetaballData();
        }
    }

    // 如果在编辑器指定RT,检查
    if (SDF_RenderTarget)
    {
        // 防御性编程:检查指定的RT是否开启了UAV支持
        if (!SDF_RenderTarget->bCanCreateUAV)
        {
            // 打印一个清晰的错误日志,告诉用户如何修复
            UE_LOG(LogTemp, Error, TEXT("MetaballManager: The assigned SDF_RenderTarget '%s' does not have 'Can be UAV' enabled. Please open the asset and enable it."), *SDF_RenderTarget->GetName());
            
            // 选择禁用Actor,防止后续崩溃
            SetActorTickEnabled(false);
            return;
        }
    }
    else
    {
        SDF_RenderTarget = NewObject<UTextureRenderTargetVolume>(this);
        SDF_RenderTarget->bCanCreateUAV = true;
        SDF_RenderTarget->Init(GridDimensions.X, GridDimensions.Y, GridDimensions.Z, PF_R32_FLOAT);
        SDF_RenderTarget->UpdateResource();
    }

    // --- 创建动态材质实例并应用 ---
    if (BaseMaterial && MeshComponent)
    {
        MeshComponent->SetMaterial(0, BaseMaterial);
        // 基于BaseMaterial为MeshComponent的第一个材质槽(索引0)创建一个MID
        DynamicMaterialInstance = MeshComponent->CreateAndSetMaterialInstanceDynamic(0);

        if (DynamicMaterialInstance)
        {
            // 将动态创建的RT设置到MID的"SDFTexture"参数上
            // "SDFTexture" 这个名字必须与材质编辑器中设置的参数名完全一致
            DynamicMaterialInstance->SetTextureParameterValue(TEXT("SDFTexture"), SDF_RenderTarget);
            
            DynamicMaterialInstance->SetVectorParameterValue(TEXT("BoundsMin"), GetActorLocation() - BoundsSize / 2.0);
            DynamicMaterialInstance->SetVectorParameterValue(TEXT("BoundsMax"), GetActorLocation() - BoundsSize / 2.0 + BoundsSize);
        }
    }
    else
    {
        UE_LOG(LogTemp, Warning, TEXT("MetaballManager: BaseMaterial is not assigned!"));
    }
    
    // 获取渲染器模块并绑定渲染函数到委托上
    IRendererModule* RendererModule = FModuleManager::GetModulePtr<IRendererModule>("Renderer");
    if (RendererModule)
    {
        // 创建一个委托对象,并将其绑定到成员函数上
        FPostOpaqueRenderDelegate PostOpaqueRenderDelegate = FPostOpaqueRenderDelegate::CreateUObject(this, &AMetaballManager::Render_RenderThread);
        
        // 使用Register函数注册委托,并保存返回的句柄
        PostOpaqueRenderDelegateHandle = RendererModule->RegisterPostOpaqueRenderDelegate(PostOpaqueRenderDelegate);
    }
    
}

void AMetaballManager::EndPlay(const EEndPlayReason::Type EndPlayReason)
{
    Super::EndPlay(EndPlayReason);
    // 可以在这里清理资源
}

void AMetaballManager::UpdateMetaballs(float DeltaTime)
{

    if (DataSource)
    {
        // 更新数据源
        DataSource->Tick(DeltaTime);

        // 如果是CPU数据源,就拉取数据
        if (UCPU_MetaballDataSource* CpuSource = Cast<UCPU_MetaballDataSource>(DataSource))
        {
            // 锁定,将数据拷贝到后台缓冲区
            FScopeLock Lock(&BufferSwapCriticalSection);
            MetaballDataBackBuffer = CpuSource->GetMetaballData();
        }
    }
    
    // --- 在游戏线程更新缓存 ---
    CachedBoundsMin_RenderThread = static_cast<FVector3f>(GetActorLocation() - BoundsSize / 2.0);
    CachedBoundsSize_RenderThread = static_cast<FVector3f>(BoundsSize);
    CachedGridDimensions_RenderThread = GridDimensions;
    CachedSmoothnessFactor_RenderThread = SmoothnessFactor;
}

void AMetaballManager::BeginDestroy()
{
    IRendererModule* RendererModule = FModuleManager::GetModulePtr<IRendererModule>("Renderer");
    if (RendererModule)
    {
        // 使用句柄来移除(解绑)委托
        RendererModule->RemovePostOpaqueRenderDelegate(PostOpaqueRenderDelegateHandle);
    }
    
    Super::BeginDestroy();
}

void AMetaballManager::Render_RenderThread(FPostOpaqueRenderParameters& Parameters)
{
    // 从参数中获取RDG构建器和RHI命令列表
    FRDGBuilder& GraphBuilder = *Parameters.GraphBuilder;
    
    int32 MetaballCount = 0;
    FRDGBufferRef MetaballBufferRDG = nullptr;
    // --- 分支:处理CPU数据源 ---
    if (Cast<UCPU_MetaballDataSource>(DataSource))
    {
        // 在渲染线程开始时,立即交换前后缓冲区
        {
            FScopeLock Lock(&BufferSwapCriticalSection);
            Swap(MetaballDataFrontBuffer, MetaballDataBackBuffer);
        }

        MetaballCount = MetaballDataFrontBuffer.Num();
        if (MetaballCount == 0) return;

        // 1. 在RDG中创建一个目标GPU缓冲区
        const uint32 BufferSize = MetaballCount * sizeof(FMetaballData);
        MetaballBufferRDG = GraphBuilder.CreateBuffer(
            FRDGBufferDesc::CreateStructuredDesc(sizeof(FMetaballData), MetaballCount),
            TEXT("MetaballDataBuffer_RDG")
        );

        // 2. 将CPU数据上传到这个RDG缓冲区
        // 这是最关键的一步:使用 GraphBuilder.QueueBufferUpload
        GraphBuilder.QueueBufferUpload(
            MetaballBufferRDG, // 目标RDG Buffer
            MetaballDataFrontBuffer.GetData(), // 源CPU数据指针
            BufferSize, // 数据大小
            ERDGInitialDataFlags::NoCopy // 告诉RDG我们保证在lambda执行前数据有效,避免不必要的内部拷贝
        );
    }
    else
    {
        // TODO: 在这里处理UNiagara_MetaballDataSource
    }

    // --- 数据校验 ---
    if (!DataSource || !SDF_RenderTarget || !SDF_RenderTarget->GetResource())
    {
        UE_LOG(LogTemp, Warning, TEXT("AMetaballManager::Render_RenderThread: Data false"));
        return;
    }
    
    if (!MetaballBufferRDG)
    {
        UE_LOG(LogTemp, Warning, TEXT("AMetaballManager::Render_RenderThread: MetaballBufferRDG nullptr"));
        return;
    }
    // --- RDG Pass 设置 ---

    // 1. 分配着色器参数
    FMetaballSDF_CS::FParameters* PassParameters = GraphBuilder.AllocParameters<FMetaballSDF_CS::FParameters>();
    
    // 2. 将外部资源(我们长期持有的UObject)注册到RDG中,获得RDG句柄
    FRDGTextureRef SDF_RenderTargetRDG = GraphBuilder.RegisterExternalTexture(CreateRenderTarget(SDF_RenderTarget->GetResource()->GetTextureRHI(), TEXT("SDFRenderTarget")));

    // 3. 填充Pass参数
    PassParameters->Metaballs = GraphBuilder.CreateSRV(MetaballBufferRDG);
    PassParameters->SDF_Output = GraphBuilder.CreateUAV(SDF_RenderTargetRDG);
    PassParameters->NumMetaballs = MetaballCount;
    PassParameters->SmoothnessFactor = CachedSmoothnessFactor_RenderThread;
    PassParameters->BoundsMin = CachedBoundsMin_RenderThread;
    PassParameters->BoundsSize = CachedBoundsSize_RenderThread;
    PassParameters->GridDimensions = FVector3f(CachedGridDimensions_RenderThread.X, CachedGridDimensions_RenderThread.Y, CachedGridDimensions_RenderThread.Z);

    // 4. 获取着色器实例
    TShaderMapRef<FMetaballSDF_CS> ComputeShader(GetGlobalShaderMap(GMaxRHIFeatureLevel));
    
    // 计算线程组数量
    FIntVector GroupCount = FIntVector(
        FMath::DivideAndRoundUp(CachedGridDimensions_RenderThread.X, 8),
        FMath::DivideAndRoundUp(CachedGridDimensions_RenderThread.Y, 8),
        FMath::DivideAndRoundUp(CachedGridDimensions_RenderThread.Z, 8)
    );

    // 5. 向GraphBuilder添加一个Compute Pass
    GraphBuilder.AddPass(
        RDG_EVENT_NAME("MetaballSDF_CS"), // Pass在调试工具(如RenderDoc)中的名字
        PassParameters,
        ERDGPassFlags::Compute, // 声明这是一个计算Pass
        [ComputeShader, PassParameters, GroupCount](FRHICommandList& RHICmdList)
        {
            // 这个lambda中的代码最终在GPU上执行
            FComputeShaderUtils::Dispatch(RHICmdList, ComputeShader, *PassParameters, GroupCount);
        }
    );

}

最后是计算着色器 MetaballSDF.usf ,大部分内容和常规计算着色器相同,下面是一些UE特有的字段:

RWTexture3D<float> SDF_Output:

  • Texture3D: 表明这是一个三维纹理资源。3D纹理是一个体素(Voxel)网格(宽 x 高 x 深)。可以把它想象成一个由小方块组成的立体像素块

  • <float>: 表示每个体素存储的数据类型是一个单精度浮点数

  • RW: 代表 Read/Write。普通的纹理在着色器中是只读的(Texture2D)。RW前缀表示这个资源是“无序访问视图”(UAV),计算着色器可以向它的任意位置写入数据

MainCS(uint3 DTid : SV_DispatchThreadID):

  • MainCS: UE中计算着色器的主函数入口点
  • DTid: Dispatch Thread ID。这是一个 uint3 (包含x, y, z)的变量,代表当前正在执行的这个线程的全局唯一ID
  • 如果纹理是 128x128x128,那么DTid的值会从 (0,0,0) 一直变化到 (127,127,127)
  • 每个线程负责计算并写入一个体素。DTid 直接对应了它要操作的 SDF_Output 纹理的坐标
// Shaders/MetaballSDF.usf
#include "/Engine/Public/Platform.ush"

// 确保这里的结构体与C++中的 FMetaballData 完全一致
struct FMetaballData
{
	float3 Position;
	float Radius;
};

// ------ 输入/输出/参数 ------

// 输入: 从数据源(CPU或Niagara)传来的所有融球数据
StructuredBuffer<FMetaballData> Metaballs;

// 输出: 我们要写入的3D SDF纹理
RWTexture3D<float> SDF_Output;

// 统一参数 (Uniforms)
uint NumMetaballs;      // 融球总数
float SmoothnessFactor; // 融合程度
float3 BoundsMin;       // 模拟空间的包围盒最小角点
float3 BoundsSize;      // 模拟空间的包围盒大小
float3 GridDimensions;  // 3D纹理的分辨率 (e.g., 128, 128, 128)

// ------ 辅助函数 ------

// Smooth Minimum: 这是融球效果的核心,平滑地混合两个SDF值
// k越大,融合的边界越锐利
float smin(float a, float b, float k)
{
	float h = clamp(0.5 + 0.5 * (b - a) / k, 0.0, 1.0);
	return lerp(b, a, h) - k * h * (1.0 - h);
}

// ------ 主函数 ------

[numthreads(8, 8, 8)]
void MainCS(uint3 DTid : SV_DispatchThreadID)
{
	// 如果线程ID超出了网格范围,直接返回
	if (any(DTid >= GridDimensions))
	{
		return;
	}

	// 1. 计算当前体素(voxel)在世界空间中的位置
	float3 WorldPos = BoundsMin + (float3(DTid) / GridDimensions) * BoundsSize;

	// 2. 初始化SDF为一个非常大的值
	float finalSDF = 99999.0;

	// 3. 遍历所有融球,计算并融合SDF
	for (uint i = 0; i < NumMetaballs; ++i)
	{
		FMetaballData ball = Metaballs[i];
		float distToBallCenter = distance(WorldPos, ball.Position);
		float ballSDF = distToBallCenter - ball.Radius;

		// 使用smooth minimum来融合当前球体和之前结果的SDF
		// 0.5 是一个比较柔和的融合系数k
		finalSDF = smin(finalSDF, ballSDF, SmoothnessFactor);
	}

	// 4. 将最终计算的SDF值写入到输出纹理
	SDF_Output[DTid] = finalSDF;
}

蓝图

创建一个蓝图 BP_MetaballM 继承自 AMetaballManager,可以看到一系列设置:

有几个要提前设置好:选择 Data Source 为CPU Metaball Data Source;为 MeshComponent 添加一个立方体静态网格,保证 BoundsSize 和其大小一致;随后将 Base Material指定为下面的 M_Raymarch 材质:

M_RayMarch材质

最后的材质没什么特殊的,使用一个 TextureObjectParameter (SDFTexture) 接收渲染好的 UTextureRenderTargetVolume ,随后就和最开始的单材质实现一致:

自定义节点代码:代码附录

效果

可以在编辑器随意添加球体,修改其位置和半径,调整融合程度(Smoothness Factor)

关于RDG和渲染管线的内容还未完全写完,有什么想法可以联系我~

附录

代码

单材质实现:

hlsl
// =======================================================================
// Ray March Cube Setup - 光线步进立方体设置
// 目的: 为后续的光线步进算法准备初始参数
// 它计算摄像机光线与一个单位立方体的交点,并输出入口点坐标和光线在立方体内的穿行距离
// 输入参数:这两个参数主要用于减少条带瑕疵的小技巧,可以先忽略
// PlaneAlignment
// MaxSteps
// =======================================================================

// --- 1: 将摄像机和光线转换到对象的局部空间 ---
// 无论物体在世界中如何移动、旋转或缩放,都可以在一个统一、简单的坐标系(局部空间)中进行计算

// 计算摄像机在对象局部空间中的位置
// 1. ResolvedView.WorldCameraOrigin: 获取摄像机在世界空间中的位置 (UE使用大世界坐标LWC)
// 2. LWCToFloat(...): 将LWC转换为标准的float类型
// 3. GetPrimitiveData(...).WorldToLocal: 获取将世界坐标转换为该对象局部坐标的变换矩阵
// 4. mul(...): 执行矩阵乘法,将世界摄像机位置转换为局部摄像机位置
float3 localcampos = mul(float4(LWCToFloat(ResolvedView.WorldCameraOrigin), 1.0), LWCToFloat(GetPrimitiveData(Parameters.PrimitiveId).WorldToLocal)).xyz;

// 计算光线在对象局部空间中的方向向量
// 1. Parameters.CameraVector: 从摄像机指向当前像素的方向向量(在世界空间)
// 2. mul(...): 将世界空间的光线向量乘以变换矩阵,得到局部空间中的光线向量
// 3. -normalize(...): 光线步进通常需要从摄像机“射出”的光线,而CameraVector是指向摄像机的,所以取反
//                    normalize将其长度变为1,成为一个标准的单位方向向量
float3 localcamvec = -normalize(mul(Parameters.CameraVector, LWCToFloat(GetPrimitiveData(Parameters.PrimitiveId).WorldToLocal)));

// --- 2: 将局部空间归一化到单位立方体 [0,1] ---
// 简化相交测试,将局部坐标系进行缩放和平移,使得立方体包围盒的范围恰好是从(0,0,0)到(1,1,1)
// 1. GetPrimitiveData(...).LocalObjectBoundsMax.x: 获取对象局部包围盒在一个轴向上的最大值(即尺寸的一半)。假设是规则立方体。
// 2. * 2: 得到立方体的总边长。
// 3. / (...): 将局部摄像机位置除以立方体尺寸,将坐标系缩放到立方体范围为 [-0.5, 0.5]。
// 4. + 0.5: 将坐标系平移,最终使立方体范围变为 [0, 1]。
localcampos = (localcampos / (GetPrimitiveData(Parameters.PrimitiveId).LocalObjectBoundsMax.x * 2)) + 0.5;

// --- 3: 光线与单位立方体相交测试 (Slab Test算法) ---
// Slab Test算法是一种高效计算光线与轴对齐包围盒(AABB)相交的方法

// 乘法更快,提升性能
float3 invraydir = 1 / localcamvec;
// 计算光线与立方体所有"前"平面(x=0, y=0, z=0)相交所需的时间(距离)t
// (plane_position - ray_origin) / ray_direction  => (0 - localcampos) * invraydir
float3 firstintersections = (0 - localcampos) * invraydir;
// 计算光线与立方体所有"后"平面(x=1, y=1, z=1)相交所需的时间(距离)t
float3 secondintersections = (1 - localcampos) * invraydir;

// 根据光线方向,找出每个轴上更近和更远的交点
// min/max自动处理了光线从正向或负向射入的情况
float3 closest = min(firstintersections, secondintersections);
float3 furthest = max(firstintersections, secondintersections);

// 计算光线真正进入立方体的时刻t0。光线必须进入所有三个轴的范围才算进入,所以取所有近交点中的最大值
float t0 = max(closest.x, max(closest.y, closest.z));
// 计算光线真正离开立方体的时刻t1。光线只要离开任何一个轴的范围就算离开,所以取所有远交点中的最小值
float t1 = min(furthest.x, min(furthest.y, furthest.z));

// --- 4 (可选): 步进对齐,减少条带瑕疵 (Banding Artifacts) ---
//通过对每个像素的起始点进行微小的抖动,来平滑采样,减少视觉上的条纹

// 计算一个基于视角和距离的抖动偏移量
float planeoffset = 1 - frac((t0 - length(localcampos - 0.5)) * MaxSteps);

// 根据用户参数PlaneAlignment(0-1),将抖动偏移应用到入口点t0上。
t0 += (planeoffset / MaxSteps) * PlaneAlignment;
// 安全检查:如果摄像机在立方体内,t0可能为负。强制从0开始,意味着步进从摄像机位置开始
t0 = max(0, t0);

// --- 5: 计算最终输出 ---
// 光线在立方体内部穿行的总长度。如果t0 > t1,说明光线未击中立方体,max(0,...)会确保厚度为0
float boxthickness = max(0, t1 - t0);

// 使用标准光线方程 P = Origin + t * Direction 计算光线进入立方体的确切3D坐标。
// 这个坐标在归一化的 [0,1] 空间中
float3 entrypos = localcampos + (max(0, t0) * localcamvec);

// 返回一个float4向量:
// .xyz 分量是入口点坐标 (entrypos)
// .w   分量是光线穿行厚度 (boxthickness)
return float4(entrypos, boxthickness);

M_RayMarch材质:

hlsl
// =======================================================================
// SimpleMetaBall - 融球渲染核心
// 目的: 在上一个节点定义的立方体内,通过光线步进渲染一个动态的融球效果
// 输入参数:
// CurPos
// MaxDist
// MaxSteps
// Radius
// stepsize
// time
// AnimationRange
// k
// =======================================================================

// --- 辅助函数 ---
struct Functions
{
    // 球体的符号距离函数 (SDF - Signed Distance Function)。
    // 返回一个点到球体表面的最短有向距离
    float Sphere(float3 RayPos, float3 center, float Radius)
    {
        // 计算点到球心的距离,然后减去半径
        // 结果 > 0: 点在球外; < 0: 点在球内; = 0: 点在球表面
        float d = distance(RayPos, center) - Radius;
        return d;
    }

    // 一个简单的伪随机数生成器
    // 输入一个种子n,输出一个看似随机但每次都固定的float4向量
    float4 hash4(float n)
    {
        float4 res = float4(0.0, 0.0, 0.0, 0.0);
        res.rgb = frac(sin(float3(n, n + 1.0, n + 2.0)) * float3(43758.5453123, 22578.1459123, 19642.3490423));
        res.a = frac(sin(n) * 758.545);
        return res;
    }

    // Metaball的SDF:计算空间中一点到整个融球聚合体的距离
    float metaball(float3 RayPos, float AnimationRange, float Radius, float k, float time)
    {
        float3 center = float3(0.5, 0.5, 0.5); // 融球的中心基点
        float accm = 0.0; // 累加器,用于存储所有球体的“场能量”总和
        
        // 循环创建5个小球来组成Metaball
        for(int i = 0; i < 5; i++)
        {
            // 为每个球生成唯一的随机参数
            float4 rand = hash4(i * 1.0);
            // 计算当前点到这个特定小球的距离
            // 小球的中心位置基于时间(time)、随机数(rand.xyz)和运动幅度(AnimationRange)做周期性运动
            // 小球的半径也基于随机数(rand.w)进行缩放。
            float d = Sphere(RayPos, center + AnimationRange * float3(cos(time * rand.x), cos(time * rand.y), cos(time * rand.z)), Radius * rand.w);
            
            // 关键的融合步骤:将距离d转换为“场能量”并累加
            // k是平滑系数:k越大,融合边界越锐利;k越小,融合效果越平滑
            accm += exp(-k * d);
        }

        // 将累加的“场能量”转换回一个统一的距离值
        return -log(accm) / k; // 除以k以保持距离尺度
    }
    
    // 计算Metaball在某一点的表面法线。
    // 原理:SDF的梯度(gradient)就是其表面的法线向量。
    float3 calcNormal(float3 RayPos, float AnimationRange, float Radius, float k, float time)
    {
        const float eps = 0.002; // 一个极小的偏移量,用于近似求导
    
        // 定义三个轴向的单位向量
        const float3 v1 = float3(1.0, 0.0, 0.0);
        const float3 v2 = float3(0.0, 1.0, 0.0);
        const float3 v3 = float3(0.0, 0.0, 1.0);

        // 使用中心差分法(Central Difference)来近似计算梯度。
        // 即计算在X, Y, Z三个方向上,SDF值的微小变化率。
        float3 res = float3(metaball(RayPos + v1 * eps, AnimationRange, Radius, k, time) - metaball(RayPos - v1 * eps, AnimationRange, Radius, k, time), 
                            metaball(RayPos + v2 * eps, AnimationRange, Radius, k, time) - metaball(RayPos - v2 * eps, AnimationRange, Radius, k, time),
                            metaball(RayPos + v3 * eps, AnimationRange, Radius, k, time) - metaball(RayPos - v3 * eps, AnimationRange, Radius, k, time));
        // 将梯度向量归一化,得到单位长度的法线。
        return normalize(res);
    }
};

// --- 主光线步进循环 ---

// 获取局部空间的光线方向向量
float3 localcamvec = -normalize(mul(Parameters.CameraVector, LWCToFloat(GetPrimitiveData(Parameters.PrimitiveId).WorldToLocal)));
// 初始化变量
float totaldist = 0; // 记录总步进距离
float4 Ret = float4(0.0, 0.0, 0.0, 0.0); // 初始化返回颜色为透明黑
float3 RayPos = CurPos; // CurPos是上个节点计算出的光线入口点,作为步进的起始位置

// 实例化工具函数集
Functions f;

// 开始光线步进循环
for (int i = 0; i < MaxSteps; i++)
{	
    // 计算当前光线位置到Metaball表面的距离
    float d = f.metaball(RayPos, AnimationRange, Radius, k, time);

    // 检查是否击中表面(距离是否小于一个很小的阈值)
	if(d < 0.01)
    {
        // 如果击中,计算该点的法线向量
        Ret.rgb = f.calcNormal(RayPos, AnimationRange, Radius, k, time);
        Ret.a = 1.0; // 设置为完全不透明
        break; // 找到表面,跳出循环,完成该像素的计算
    }

    // 如果没有击中,则沿着光线方向前进一个固定的步长(stepsize)
    // 更优化的方法是球体追踪(Sphere Tracing),步长为d,即 RayPos += localcamvec * d;
	RayPos += localcamvec * stepsize;
    totaldist += stepsize; // 累加步进距离
	
    // 边界检查:如果光线走出了[0,1]的单位立方体,或者超出了最大距离,则停止
	if (RayPos.x < 0 || RayPos.x > 1 || RayPos.y < 0 || RayPos.y > 1 || RayPos.z < 0 || RayPos.z > 1 || totaldist > MaxDist)
	{
		return 0; // 返回透明黑,表示没有击中任何东西
	}
}

// 返回最终计算出的像素颜色
return Ret;

Last updated: