NiagaraDataInterface笔记一:如何写一个NDI

NDI简介:

Niagara是虚幻引擎推出的新的粒子系统
NiagaraDataInterface就像Component之于Actor一样,能够通过用户自己的实现给Niagara提供许多功能

如何写一个NDI:

虽然有官方示例 ExampleCustomDataInterface的插件,但是有些函数的作用、以及一些注意点可能还是会有些不清楚。在此我使用一个更加简单的例子,来说明哪些函数的作用。

Let’s Start

首先的准备:

  1. 我们首先要在对应的模块的build.cs文件中引用NiagaraNiagaraCore两个模块
  2. 然后对我们的NiagaraDataInterface,补充UCALSS里的声明,并且实现UObject的PostInitProperties接口
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    UCLASS(EditInlineNew, Category = "MyNiagaraExt", meta = (DisplayName = "My NDI"))
    class NIAGARAEXT_API UMyNiagaraDataInterface : public UNiagaraDataInterface
    {
    GENERATED_BODY()
    public:
    //UObject Interface
    //在这里注册NDI,以便在粒子编辑器中能看到我们的NDI
    virtual void PostInitProperties() override;
    //UObject Interface End
    };

    void UMyNiagaraDataInterface::PostInitProperties()
    {
    Super::PostInitProperties();

    if (HasAnyFlags(RF_ClassDefaultObject))
    {
    ENiagaraTypeRegistryFlags Flags = ENiagaraTypeRegistryFlags::AllowAnyVariable | ENiagaraTypeRegistryFlags::AllowParameter;
    FNiagaraTypeRegistry::Register(FNiagaraTypeDefinition(GetClass()), Flags);
    }
    }
    编译打开项目,我们可以在粒子编辑器中看到我们自定义的NDI了
  • 如果你没有看到我们自定义的NDI,请检查你的模块是否被正确加载,最简单的办法就是在PostInitProperties()打个断点,看看程序是否运行进去了

来写一个CPU粒子用的NDI吧:

这一节中我希望我们的NDI能够在CPU粒子中被使用,并且打印一个LOG,LOG中打印一个数字

首先告诉NDI它能够在CPU粒子上运行吧

我们需要实现一个NDI的接口CanExecuteOnTarget,这个接口决定我们的NDI是否能被CPU/GPU粒子使用。
因为这里我们希望实现一个CPU粒子使用的NDI,因此,实现如下:

1
virtual bool CanExecuteOnTarget(ENiagaraSimTarget Target) const override { return Target == ENiagaraSimTarget::CPUSim; }

打印一条log吧

现在我们给我们的NDI提供一个方法,我们每调用一次这个方法打印一条log。
要实现这个目的,我们要实现两个接口GetFunctionsGetVMExternalFunction

  • GetFunctions:在这里告诉粒子系统,你的NDI能够给粒子系统提供什么方法、这些方法需要什么参数、有哪些输出;无论是CPU还是GPU粒子使用的方法都要在该接口的实现中声明;
  • GetVMExternalFunction:在这里告诉粒子系统你在GetFunctions声明的CPU粒子用的方法,具体对应的函数是哪个
    具体的实现如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    void UMyNiagaraDataInterface::GetFunctions(TArray<FNiagaraFunctionSignature>& OutFunctions)
    {
    FNiagaraFunctionSignature Sig;
    {
    Sig = FNiagaraFunctionSignature();
    Sig.Name = PrintMyLogName;
    #if WITH_EDITORONLY_DATA
    Sig.Description = LOCTEXT("PrintMyLogDescription", "Print My Log.");
    #endif
    Sig.bMemberFunction = true;
    Sig.bSupportsGPU = false;
    Sig.bRequiresExecPin = true;
    Sig.AddInput(FNiagaraVariable(FNiagaraTypeDefinition(GetClass()), TEXT("My NDI")));
    OutFunctions.Add(Sig);
    }

    }

    void UMyNiagaraDataInterface::GetVMExternalFunction(const FVMExternalFunctionBindingInfo& BindingInfo, void* InstanceData, FVMExternalFunction& OutFunc)
    {
    if (BindingInfo.Name == PrintMyLogName)
    {
    OutFunc = FVMExternalFunction::CreateLambda([this](FVectorVMExternalFunctionContext& Context) { UE_LOG(LogNiagaraExt, Log, TEXT("Texwood0935 Debug My NDI: Print My Log!")) });
    }
    }

编译重启项目,我们来看看效果

  • 仔细观察,我们会发现节点分为可以直接执行的,和不直接执行的。他们的区别在于声明节点时Sig.bRequiresExecPin的值

再加两个功能

现在我想给我的NDI再加两个功能:设置一个变量的值以及输出一个变量
参照第二点,我们通过GetFunctionsGetVMExternalFunction添加并且绑定两个节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
void UMyNiagaraDataInterface::GetFunctions(TArray<FNiagaraFunctionSignature>& OutFunctions)
{
FNiagaraFunctionSignature Sig;
{ ... }

{
Sig = FNiagaraFunctionSignature();
Sig.Name = SetMyNumName;
#if WITH_EDITORONLY_DATA
Sig.Description = LOCTEXT("SetMyNumDescription", "Set My Num.");
#endif
Sig.bMemberFunction = true;
Sig.bSupportsGPU = false;
Sig.bRequiresExecPin = true;
Sig.AddInput(FNiagaraVariable(FNiagaraTypeDefinition(GetClass()), TEXT("My NDI")));
Sig.AddInput(FNiagaraVariable(FNiagaraTypeDefinition::GetFloatDef(), TEXT("InputNum")));
OutFunctions.Add(Sig);
}

{
Sig = FNiagaraFunctionSignature();
Sig.Name = GetMyNumName;
#if WITH_EDITORONLY_DATA
Sig.Description = LOCTEXT("GetMyNumDescription", "Get My Num.");
#endif
Sig.bMemberFunction = true;
Sig.bSupportsGPU = false;
//Sig.bRequiresExecPin = true;
Sig.AddInput(FNiagaraVariable(FNiagaraTypeDefinition(GetClass()), TEXT("My NDI")));
Sig.AddOutput(FNiagaraVariable(FNiagaraTypeDefinition::GetFloatDef(), TEXT("MyNum")), LOCTEXT("OutputNumDescription", "Returns MyNum"));
OutFunctions.Add(Sig);
}

}

DEFINE_NDI_DIRECT_FUNC_BINDER(UMyNiagaraDataInterface, SetMyNumVM);

void UMyNiagaraDataInterface::GetVMExternalFunction(const FVMExternalFunctionBindingInfo& BindingInfo, void* InstanceData, FVMExternalFunction& OutFunc)
{
if (BindingInfo.Name == PrintMyLogName)
{
...
}
else if (BindingInfo.Name == SetMyNumName)
{
//除了Lambda函数、通过Lambda函数调用NDI的函数,我们还可以通过宏来绑定函数
NDI_FUNC_BINDER(UMyNiagaraDataInterface, SetMyNumVM)::Bind(this, OutFunc);
}
else if (BindingInfo.Name == GetMyNumName)
{
OutFunc = FVMExternalFunction::CreateLambda([this](FVectorVMExternalFunctionContext& Context) {
this->GetMyNumVM(Context);
});
}
}

SetMyNumVMGetMyNumVM就是我们具体的实现了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
void UMyNiagaraDataInterface::SetMyNumVM(FVectorVMExternalFunctionContext& Context)
{
FNDIInputParam<float> InMyNum(Context);
MyNum = InMyNum.GetAndAdvance();
}

void UMyNiagaraDataInterface::GetMyNumVM(FVectorVMExternalFunctionContext& Context)
{
FNDIOutputParam<float> OutMyNum(Context);
OutMyNum.SetAndAdvance(MyNum);
}

这里要注意一点:无论是FNDIInputParam还是FNDIOutputParam他们的模板填充进去的类型都只能是和float类型同样大小的类型,除非在引擎里事先定义好。这意味着并不是所有的类型都可以作为输入或者输出来给我们使用。

编译重启项目,我们来看看效果


不满足于此,我希望每个粒子都有属于它的”MyNum”

每个粒子都要有属于它的”MyNum”,这意味着我们需要一个结构让每个粒子都储存一份MyNum的数据。使用一个结构体来储存:

1
2
3
4
5
// the struct used to store our data interface data
struct FMyNiagaraDataInterfaceInstanceData
{
float MyNum = 0;
};

对于NDI我们需要干几件事:告诉NDI结构体的大小以及每个粒子都初始化一份结构体数据还有在每个粒子销毁的时候释放我们的结构体

1
2
3
4
5
6
7
8
9
10
11
12
13
14
	virtual int32 PerInstanceDataSize() const override { return sizeof(FMyNiagaraDataInterfaceInstanceData); }

bool UMyNiagaraDataInterface::InitPerInstanceData(void* PerInstanceData, FNiagaraSystemInstance* SystemInstance)
{
FMyNiagaraDataInterfaceInstanceData* InstanceData = new (PerInstanceData) FMyNiagaraDataInterfaceInstanceData;
InstanceData->MyNum = 0;
return true;
}

void UMyNiagaraDataInterface::DestroyPerInstanceData(void* PerInstanceData, FNiagaraSystemInstance* SystemInstance)
{
FMyNiagaraDataInterfaceInstanceData* InstData = static_cast<FMyNiagaraDataInterfaceInstanceData*>(PerInstanceData);
InstData->~FMyNiagaraDataInterfaceInstanceData();
}

这里有一点要注意:PerInstanceDataSize不为0的时候,每一个VMFunction开头一定要有VectorVM::FUserPtrHandler<FMyNiagaraDataInterfaceInstanceData> InstData(Context),否则使用相应的方法时,会引发崩溃。

编译重启项目,我们来看看效果


其他一些

除了上面提到的一些接口,还有些比较重要的接口:

  • PerInstanceTick:每个粒子的Tick。
  • PerInstanceTickPostSimulate:与上面的Tick不同之处在于Tick的阶段、时机不同
  • Equals:判断两个NDI是否相等
  • CopyToInternal:将一个NDI的数据拷贝到另一个
    上述都是一些比较重要的接口。其中,如果没有实现EqualsCopyToInternal可能会导致NDI拷贝的时候,有参数没有被正确拷贝。作者就遇到过没实现这两个接口,导致在粒子的scratch module传参错误的情况

来实现一个GPU粒子用的NDI:

这一节中我希望我们的NDI能够在GPU粒子中获取到MyNum的值

第一步

和CPU粒子的第一步相同,我们先让NDI能够在GPU粒子上运行

1
virtual bool CanExecuteOnTarget(ENiagaraSimTarget Target) const override { return Target == ENiagaraSimTarget::CPUSim || Target == ENiagaraSimTarget::GPUComputeSim; }

除此之外GPU粒子还需要额外写Shader,因此我们还需要模块里对shader路径进行映射

1
2
3
4
5
6
void FNiagaraExtModule::StartupModule()
{
// map the shader dir so we can use it in the data interface
FString NiagaraExtShaderDir = FPaths::Combine(FPaths::GameSourceDir(), TEXT("NiagaraExt/Shaders"));
AddShaderSourceDirectoryMapping(TEXT("/NiagaraExt"), NiagaraExtShaderDir);
}

第二步:绑定shader以及相关的hlsl函数

正如一般的shader声明流程,我们首先在类里用宏声明shader参数的结构体

1
2
3
4
5
6
7
8
9
class NIAGARAEXT_API UMyNiagaraDataInterface : public UNiagaraDataInterface
{
GENERATED_BODY()

BEGIN_SHADER_PARAMETER_STRUCT(FShaderParameters, )
SHADER_PARAMETER(float, MyNum)
END_SHADER_PARAMETER_STRUCT()
...
}

然后我们实现两个绑定shader参数的接口

1
2
3
4
5
6
7
8
9
10
void UMyNiagaraDataInterface::BuildShaderParameters(FNiagaraShaderParametersBuilder& ShaderParametersBuilder) const
{
ShaderParametersBuilder.AddNestedStruct<FShaderParameters>();
}

void UMyNiagaraDataInterface::SetShaderParameters(const FNiagaraDataInterfaceSetShaderParametersContext& Context) const
{
FShaderParameters* ShaderParameters = Context.GetParameterNestedStruct<FShaderParameters>();
ShaderParameters->MyNum = MyNum;
}

然后再是一些有关shader编译、hlsl函数参数相关的接口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
bool UMyNiagaraDataInterface::AppendCompileHash(FNiagaraCompileHashVisitor* InVisitor) const
{
if (!Super::AppendCompileHash(InVisitor))
{
return false;
}

InVisitor->UpdateShaderFile(MyNiagaraDataInterfaceShaderFile);
InVisitor->UpdateShaderParameters<FShaderParameters>();
return true;
}

bool UMyNiagaraDataInterface::GetFunctionHLSL(const FNiagaraDataInterfaceGPUParamInfo& ParamInfo, const FNiagaraDataInterfaceGeneratedFunction& FunctionInfo, int FunctionInstanceIndex, FString& OutHLSL)
{
return FunctionInfo.DefinitionName == GetMyNumName;
}

void UMyNiagaraDataInterface::GetParameterDefinitionHLSL(const FNiagaraDataInterfaceGPUParamInfo& ParamInfo, FString& OutHLSL)
{
const TMap<FString, FStringFormatArg> TemplateArgs =
{
{TEXT("ParameterName"), ParamInfo.DataInterfaceHLSLSymbol},
};
AppendTemplateHLSL(OutHLSL, MyNiagaraDataInterfaceShaderFile, TemplateArgs);
}

值得注意的是,shader中我们提供给NDI的函数以及NDI传进来的参数都需要被{ParameterName}修饰,例如:

1
2
3
4
5
6
7
// when using a template ush file, we need the _{ParameterName} appendix on global functions and parameters, because the template can be included multiple times for different data interfaces in a system.
float {ParameterName}_MyNum;

void GetMyNum_{ParameterName}(out float Out_MyNum)
{
Out_MyNum = {ParameterName}_MyNum;
}

除此之外,和PrimitiveComponent一样,NDI也需要一个Proxy
对于支持GPU粒子的NDI来说,还需要一个实现了FNiagaraDataInterfaceProxy的类以及在构造函数里为Proxy设置值

1
2
3
4
5
6
7
8
9
10
struct FMyNiagaraDataInterfaceProxy : public FNiagaraDataInterfaceProxy
{
virtual int32 PerInstanceDataPassedToRenderThreadSize() const override { return 0; }
};
...
UMyNiagaraDataInterface::UMyNiagaraDataInterface(FObjectInitializer const& ObjectInitializer)
: Super(ObjectInitializer)
{
Proxy.Reset(new FMyNiagaraDataInterfaceProxy());
}

完成了以上步骤会发现,虽然GPU粒子能够获得MyNum的值,但是只能获得默认值。也就是如果我改变了MyNum的值,GPU粒子获取的值并没有改变。
这是因为两点:

  1. SetShaderParameters里面不要使用原生的NDI的值,要用Proxy里面的值,因为这在RenderThread中
  2. CopyToInternal没有正确实现
    更正完错误后,我们的代码为:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    struct FMyNiagaraDataInterfaceProxy : public FNiagaraDataInterfaceProxy
    {
    virtual int32 PerInstanceDataPassedToRenderThreadSize() const override { return 0; }

    float MyNum = 0;
    FMyNiagaraDataInterfaceProxy() {}
    FMyNiagaraDataInterfaceProxy(float InMyNum) : MyNum(InMyNum) {}

    void UpdateMyNum(float InMyNum) { MyNum = InMyNum; }
    };

    bool UMyNiagaraDataInterface::CopyToInternal(UNiagaraDataInterface* Destination) const
    {
    if (!Super::CopyToInternal(Destination))
    {
    return false;
    }

    UMyNiagaraDataInterface* OtherTyped = CastChecked<UMyNiagaraDataInterface>(Destination);
    OtherTyped->MyNum = MyNum;

    OtherTyped->GetProxyAs<FMyNiagaraDataInterfaceProxy>()->UpdateMyNum(MyNum);

    return true;
    }

    void UMyNiagaraDataInterface::SetShaderParameters(const FNiagaraDataInterfaceSetShaderParametersContext& Context) const
    {
    FMyNiagaraDataInterfaceProxy& DataInterfaceProxy = Context.GetProxy<FMyNiagaraDataInterfaceProxy>();
    FShaderParameters* ShaderParameters = Context.GetParameterNestedStruct<FShaderParameters>();
    ShaderParameters->MyNum = DataInterfaceProxy.MyNum;
    }

编译重启项目,我们来看看效果


最后的其他

其实上面还有很多点没提到,比如:虚幻很多东西设置参数都是通过名称来设置,但是我们的节点没法塞字符串类型的输入进去,这个时候该怎么做之类的。
官方有很多NDI,去参考官方NDI的实现很多疑问也会被解决,以后有空也会写些有关于官方NDI源码分析的笔记。