游戏引擎应用-Unreal Engine的Niagara Fluids Template分析 ...
0. 前言本人之前在研究基于网格的方法,链接如下,这里面写了一些与流体模拟有关的基础知识,后面想往Unity上迁移,但是感觉整个流体设计框架是个问题,故分析Niagara Fluids的设计思路以作参考,当然Niagara Fluids基于Niagara实现,自己设计的框架可以更灵活一些。前面自己学习了些网格方法的基础,现在利用Niagara学习一下流体模拟的整体框架设计。
不过个人是Niagara的新手,所以是小白视角,可能根据认知写的详略不当,见谅。前面有一篇理论推导,在后面涉及到流体模拟物理方法的地方需要用到。
1. Grid 3D Gas Smoke
首先在Unreal Engine中下载好Niagara以及Niagara Fluids插件,在Content Drawer中右键选择Niagara System,选择New system from a template or behavior example,再点击左边的Templates,选择Grid 3D Gas Smoke,右键Edit就可以打开这个蓝图了(或者也可以放在一个Level里面去,这样还可以看见User控制的参数),我自己是先从这个例子分析起的。
1.1 Emitter部分
对于这个例子来说,Emitter部分更像是一个变量定义和环境参数设定的地方。GPU和CPU的使用设定在Properties的Sim Target选项;系统的最大步长在Properties的Max Delta Time Per Tick。
1.1.1 Emitter Spawn
这个部分设定了Grid的属性。
①Grid 3D GAS CONTROLS SPAWN设置了Grid、Simulation等参数,不过这些单单看这个List肯定是晕头转向,我们在需要拿取的时候再回来看设置,其中在Grid中有部分参数是用Link的方法赋值的,并且Link的变量上面有USER标识,这部分就是我们用户可以实例化之后修改的量,这些量对于每个实例都可以自由设置不相互影响。这个中间的红框就是这个Module的输入界面,双击蓝图中的这一条就可以看见其非常详细的参数配置,不过我们后面在使用一个量的时候,会解释其来源,所以可以先只需要稍微浏览。还可以通过查看Attribute前面的Tag判断他是不是在Grid 3D GAS CONTROLS SPAWN被初始化的。
USER标识变量
②Grid 3D GAS SCALABILITY SPAWN是设置了网格的可扩展性,通俗来讲就是,网格在世界空间中的X、Y、Z轴的长度是固定的,然而我们怎么通过降分辨率提升性能呢?这就需要XYZ轴能够支持的网格数减小,同时,对于不同的场量,需要的场的分辨率也不尽相同,这样降低了模拟的网格分辨率,这样就能减少场量计算次数。同样我们还可以限制线性方程求解的迭代次数提高性能。这些都是在性能和效果中间做抉择。双击可以点开,可以看见根据输入Quality与Engine的QualityLevel匹配,设置了很多与上面说的可扩展性相关的数值,在我的机器上,如果调整为了Epic,那么Editor里面的Niagara就会执行这一步。
③Set,纯看数据太复杂了,下面的Set就是一个比较具体的东西,Set设定了6个Grid,每个Grid都是Grid3D Collection类型,这个主要在NiagaraDataInterfaceGrid3DCollection.h中。其中每个网格体可以设定自己的Override Buffer Format,这个部分逻辑在下面,可以发现默认的GridFormat是HalfFloat半精度。
//------NiagaraDataInterfaceGrid3DCollection.cpp
ENiagaraGpuBufferFormat BufferFormat = bOverrideFormat ? OverrideBufferFormat : GetDefault<UNiagaraSettings>()->DefaultGridFormat;
if (GNiagaraGrid3DOverrideFormat >= int32(ENiagaraGpuBufferFormat::Float) && (GNiagaraGrid3DOverrideFormat < int32(ENiagaraGpuBufferFormat::Max)))
{
BufferFormat = ENiagaraGpuBufferFormat(GNiagaraGrid3DOverrideFormat);
}
InstanceData->PixelFormat = FNiagaraUtilities::BufferFormatToPixelFormat(BufferFormat);
//------NiagaraCommon.h
/** Niagara supported buffer formats on the GPU. */
UENUM()
enum class ENiagaraGpuBufferFormat : uint8
{
/** 32-bit per channel floating point, range [-3.402823 x 10^38, 3.402823 x 10^38] */
Float,
/** 16-bit per channel floating point, range [-65504, 65504] */
HalfFloat,
/** 8-bit per channel fixed point, range . */
UnsignedNormalizedByte,
Max UMETA(Hidden),
};
//------NiagaraSettings.h
/** The default buffer format used by all Niagara Grid Data Interfaces unless overridden. */
UPROPERTY(config, EditAnywhere, Category = Renderer)
ENiagaraGpuBufferFormat DefaultGridFormat = ENiagaraGpuBufferFormat::HalfFloat;
④Set Resolution,设定好Grid之后,我们还需要对Grid其他的参数进行配置,下面的函数就是对这几个Grid的Resolution分别进行设置,双击可以进入一个小复杂的蓝图,这个蓝图是Grid3D_SetResolution,逻辑可以用下面指出的Switcher分离开,我们详细看下他的Grid有多少种初始化Resolution的方式
Grid3D_SetResolution
Switcher就非常有意思,这里面就是表示了这个Module可以支持的初始化方式,在这个例子中用了其中两种初始化方式,第一种是MaxAxis,第二种就是OtherGrid。在示例中,几个不同的Grid设定如下:
Resolution Method初始化方法,特别的是,这个流控制是后面的节点控制前面的节点,有点动画蓝图逻辑的味道
Max Axis:
输入NumCellsMaxAxis、Resolution Mult与WorldGridExtents,只有Scalar Grid用此初始化方式。
其中,NumCellsMaxAxis在Grid 3D GAS CONTROLS SPAWN中被User Input ResolutionMaxAxis初始化,Resolution Mult为1,WorldGridExtents在Grid 3D GAS CONTROLS SPAWN中被User Input WorldSpaceSize初始化。
Max Axis的初始化方法如下,其本质是根据空间网格最长边能包含的网格设定范围,所以后续就需要考虑不足的边进行调整,调整完毕之后再将目前的WorldGridExtents与NumCellsXYZ作为输出。
float CellSize = max(WorldGridExtents.z, max(WorldGridExtents.x, WorldGridExtents.y)) / NumCellsMaxAxis;
NumCellsX = floor(WorldGridExtents.x / CellSize);
NumCellsY = floor(WorldGridExtents.y / CellSize);
NumCellsZ = floor(WorldGridExtents.z / CellSize);
//...对不足的边进行调整
Out_WorldGridExtents = float3(NumCellsX, NumCellsY, NumCellsZ) * CellSize;根据计算出来的NumCells和Resolution Mult相乘,得到最终的NumCells,也就是每条边应该有多少个方格,赋值给当前的Grid。再通过上面输出的实际空间WorldGridExtents与NumCells求出均匀网格的小格WorldCellSize,同时输出WorldGridExtents,这些数据都是后续计算的时候需要的。
Other Grid:
输入OtherGrid、Resolution Mult与WorldGridExtents,除了Scalar Grid都使用这种方式。
其中,计算方法就是从OtherGrid获取NumCellsXYZ,WorldGridExtents直接使用输入,后续的方法就是和上面计算完毕之后相同了,等于说同等条件下的网格只需要计算一个Max Axis,其他就可以“渔翁得利”了。
所以剩下的网格的区别就是OtherGrid、Resolution Mult。LightingGrid与SimGrid都是使用Scalar Grid作为OtherGrid,其Resolution Mult分别在Grid 3D GAS CONTROLS SPAWN被赋值。
而剩下三个网格就是SimGrid的直接复制,因为网格总共就只有两种,一种是给模拟的,一种是给光照的,如果模拟默认场量的网格都一致,那么就没必要调整了。
这里是Input显示,双击中间蓝图的这一项可以进去看赋值关系,因为有些东西很少用,在内部Input就被标记了Advanced Display属性,所以要记得开Advanced
需要注意的是,整个系统存在很多同名变量,区别在于其Tag,Tag标记的顺序与Set里面有关。所以在后面就可以看见不同Tag的WorldCellSize与WorldGridExtents,而其序号就对应了Set中的顺序(因为SimGrid是第一个,按理来说是000,在程序自动生成的时候不给000序号,所以其没有序号),用的比较多的就是004号Scalar Grid。
⑤Modify Grid Scale提供了一种Scale的初始化,其中标量场输入提供ScalarGrid的WorldCellSize,模拟场输入提供SimGrid的WorldCellSize,WorldGridExtents也是SimGrid的,还输入了一个引擎提供的RenderScale以及恒为1的SimDxScale。
根据这些Scale信息,模块得到了Scalar的dx、标准的dx、Render的dx,以及WorldRenderGrid的Scale。计算如下,等于说就是给几个原本的量乘上了其对应的Scale。如果在这里对这几个不同的Grid用处不太清晰,下面一步就提供了对应关系。
⑥Create Grid Attribute Indices设定了多个Grid的Index,其中Velocity为SimGrid,Density、Temperature为ScalarGrid,SolidVelocity、Boundary为TransientGrid,Pressure为PressureGrid,SimFloat为TemporaryGrid。这几个Grid都是之前初始化过的Grid,所以其Size等属性已经存在了。里面有写Get Attribule Index的函数,这个的综合分析可以在目前的网页搜索:Link To 1.2.1 ⑥(还方便回来)
⑦g&SimRT两个地方分别是一个Set,先Set重力常数,从输入的标签可以看出来Gravity是从哪里初始化的,SimRT是模拟时使用的Render Target,应该是方便HLSL读取的一个Texture,这一条里面初始化很简单是因为下一步就是用Grid去初始化这个Render Target
⑧Grid 3D Init RT是给刚刚设置好的数据结构初始化,里面的结构也非常简单,就是将RenderTarget的Size赋值成了参考的Grid的NumCellsXYZ,并且用一个Resolution Mult给Scale一下,不过这个例子中Resolution Mult就是1,所以就等于将Grid三个轴上的Cell数量给RenderTarget这样一个三维Texture。
至此,如果自己琢磨一下,整个过程其实逻辑部分并不复杂,复杂的是无尽的参数,而这些参数因为Tag非常好的展示了部分集中初始化的参数位置,比如Grid 3D GAS CONTROLS SPAWN、以及后面的Grid 3D GAS CONTROLS UPDATE,其实整体阅读难度是大大降低了的。
1.1.2 Emitter Update
①Emitter State似乎是Niagara用来控制粒子系统运行的东西,这里就是使用系统自动的Update参数,所以暂时可以先不管。
②Grid 3D GAS CONTROLS UPDATE和上面的那个异曲同工,就是基础参数配置,后续用到的Attribute前面的Tag可以看到是否从这里初始化,主要配置的参数类型是Turbulence与Render。
③Grid 3D GAS SCALABILITY UPDATE主要调整了一些渲染的伸缩性。
④dt对DeltaTime的设置,这样的初始化应该是这个系统的一个特性,其中OverrideDeltaTime在这个例子中为False,所以采用的dt就是引擎提供的DeltaTime与上面设定的DeltaTimeScale相乘,DeltaTimeScale在这个例子中恒为1。
⑤Grid 3D Create Unit to World Transform是初始化局部空间到世界空间的坐标变换,其中一个比较核心的变换就是UnitToWorld,其表明的意思是从Local(0~1,0~1,0~1)转换到世界坐标系,因为蓝图画的很乱,下面展示HLSL部分,并且对其输入进行解释。
UnitToWorld的变换构成
TLocal将(0~1)^3转化到(-0.5~0.5)^3,
S的三个轴上的值就是WorldGridExtents的值,此时结果就是长度与世界坐标对应的了,
再进行RLocal,这个变换很有名堂,根据Camera Facing的状态,会有不同的LocalRotation以及GlobalRotation,这个例子中CameraFacing默认关闭,可能在部分2D的例子中才是开启的,关闭的时候RLocal为单位矩阵,RotationMatrix为Component的Rotation变换。
Rotation的初始化
TPivot是Scale之后的局部空间中心,LocalPivot(0,0,0.5)与WorldGridExtents相乘得到的,这个变换是因为之前做了中心变换,而整个Actor的中心在下表面中心,这一步变换就是将坐标系转化为Actor的局部坐标系。
R&T就是在Actor的局部坐标系转化到世界坐标系。整个UnitToWorld的计算方案就是如此,基本上把这个模块的所有计算都涉及到了,其他的变换在后续需要使用的时候会稍微解释下其功能。所以现在就有这样几个坐标系,名字是我现编的。
一个是UnitLocal,就是从左下角(0,0,0)到右上角(1,1,1);
一个是ActorLocal,左下角(-WorldGridExtents.x/2,-WorldGridExtents.y/2,0),右上角(WorldGridExtents.x /2,WorldGridExtents.y/2,WorldGridExtents.z);
以及世界坐标系,这里UnitToWorld就是UnitLocal到世界坐标系。
⑥Grid 3D Draw Bounds Overlay是绘制整个调整过后的Grid,其绘制由用户(以及Grid 3D GAS SCALABILITY SPAWN)控制,开启之后会在世界坐标绘制边界矩形,在边界矩阵六个方向中央绘制表示CellSize的小矩形。如果你理解上面UnitToWorld的计算方式,这个就很简单了。
⑦Set这个Set看起来像是一个Cache。
⑧Grid 3D Set Bounds双击打开,很简单的逻辑,就是将ActorLocal的左下角和右上角设置到Emitter Properties中。
1.2 Particle部分
这个就是求解的地方,注意每个Generic Simulation Stage Settings里面都可以设置,下文中,带上初始化位置TAG的控制变量,将不会被赘述初始化位置,请读者注意,如果要找,首先查看输入,再看Link关系或者直接数值初始化,如果是Link,那么查看TAG,寻找其初始化方法。
①Data Interface:设置这个Stage中使用的DataInterface,暂时不知道是怎样的运行逻辑,但是可以观察到每个Stage里面Set的所有Grid其数据类型都是与DataInterface一致的。
②Execute Behavior:可以设置初始化执行还是运行时执行。
1.2.1 Initial Scalar Grid
这里面逻辑很简单,就是初始化Density与Temperature,每个GridCell设置为0。
1.2.2 Initial Sim Grid
①Grid 3D Turbulence这个有点小复杂,输入Calculate Turbulence控制是否计算,不然就是(0,0,0),如果需要计算,最主要是这两个计算过程,左边的是利用Density、Temperature与预设的值求出Gain,然后利用Gain在3D Perlin Noise里面采样,怎么说呢?感觉用处不大。
②Set Velocity将之前计算出来的Turbulence结果作为Velocity(SimGrid)的初始化。
1.2.3 Initial Lighting Grid
设置两个LightGird的初值。
1.2.4 Compute Curl
①Grid 3D Compute Curl内置的计算旋度的方法,这里面可以稍微看下Data Interface是怎么运作的,我浅看了一下,确实有点反直觉,其中我们主要分析GetGridValue是怎么定义的
// NiagaraHlslTranslator.cpp
// 这个文件非常庞大,其功能是将蓝图中需要的信息转化为HLSL文件,我讲HLSL函数调用的部分提取出来了
const FNiagaraTranslateResults &FHlslNiagaraTranslator::Translate(
const FNiagaraCompileRequestData* InCompileData, const FNiagaraCompileRequestDuplicateData* InCompileDuplicateData,
const FNiagaraCompileOptions& InCompileOptions, FHlslNiagaraTranslatorOptions InTranslateOptions)
{
//...
if (TranslateResults.bHLSLGenSucceeded)
{
//...
if (TranslationOptions.SimTarget == ENiagaraSimTarget::GPUComputeSim)
{
FString DataInterfaceHLSL;
DefineDataInterfaceHLSL(DataInterfaceHLSL); //---------------------↓
HlslOutput += DataInterfaceHLSL;
DefineExternalFunctionsHLSL(HlslOutput);
HlslOutput += StageSetupAndTeardownHLSL;
}
//...
}
//...
}
// NiagaraHlslTranslator.cpp
void FHlslNiagaraTranslator::DefineDataInterfaceHLSL(FString& InHlslOutput)
{
FString InterfaceCommonHLSL;
FString InterfaceUniformHLSL;
FString InterfaceFunctionHLSL;
TSet<FName> InterfaceClasses;
for (int32 i = 0; i < CompilationOutput.ScriptData.DataInterfaceInfo.Num(); i++)
{
FNiagaraScriptDataInterfaceCompileInfo& Info = CompilationOutput.ScriptData.DataInterfaceInfo;
UNiagaraDataInterface* CDO = CompileDuplicateData->GetDuplicatedDataInterfaceCDOForClass(Info.Type.GetClass());
check(CDO != nullptr);
if (CDO && CDO->CanExecuteOnTarget(ENiagaraSimTarget::GPUComputeSim))
{
if ( !InterfaceClasses.Contains(Info.Type.GetFName()) )
{
第一类代码 CDO->GetCommonHLSL(InterfaceCommonHLSL);
InterfaceClasses.Add(Info.Type.GetFName());
}
FNiagaraDataInterfaceGPUParamInfo& DIInstanceInfo = DIParamInfo.AddDefaulted_GetRef();
ConvertCompileInfoToParamInfo(Info, DIInstanceInfo);
第二类代码 CDO->GetParameterDefinitionHLSL(DIInstanceInfo, InterfaceUniformHLSL);
// Ask the DI to generate HLSL.
TArray<FNiagaraDataInterfaceGeneratedFunction> PreviousHits;
for (int FunctionInstanceIndex = 0; FunctionInstanceIndex < DIInstanceInfo.GeneratedFunctions.Num(); ++FunctionInstanceIndex)
{
const FNiagaraDataInterfaceGeneratedFunction& DIFunc = DIInstanceInfo.GeneratedFunctions;
ensure(!PreviousHits.Contains(DIFunc));
第三类代码 const bool HlslOK = CDO->GetFunctionHLSL(DIInstanceInfo, DIFunc, FunctionInstanceIndex, InterfaceFunctionHLSL);
if (!HlslOK)
{
Error(FText::Format(LOCTEXT(&#34;GPUDataInterfaceFunctionNotImplemented&#34;, &#34;DataInterface {0} function {1} is not implemented for GPU.&#34;), FText::FromName(Info.Type.GetFName()), FText::FromName(DIFunc.DefinitionName)), nullptr, nullptr);
}
else
{
PreviousHits.Add(DIFunc);
}
}
}
else
{
Error(FText::Format(LOCTEXT(&#34;NonGPUDataInterfaceError&#34;, &#34;DataInterface {0} ({1}) cannot run on the GPU.&#34;), FText::FromName(Info.Name), FText::FromString(CDO ? CDO->GetClass()->GetName() : TEXT(&#34;&#34;))), nullptr, nullptr);
}
}
//三个部分按照顺序合成
InHlslOutput += InterfaceCommonHLSL + InterfaceUniformHLSL + InterfaceFunctionHLSL;
}
// NiagaraDataInterfaceGrid3DCollection.cpp
// 第一类代码生成的是Common部分的include(就等于说很多地方都能用的量总在了一起)
const FName UNiagaraDataInterfaceGrid3DCollection::GetValueFunctionName(&#34;GetGridValue&#34;);
//...
// UNiagaraDataInterfaceGrid3DCollection数据结构的第二类代码生成
void UNiagaraDataInterfaceGrid3DCollection::GetParameterDefinitionHLSL(const FNiagaraDataInterfaceGPUParamInfo& ParamInfo, FString& OutHLSL)
{
Super::GetParameterDefinitionHLSL(ParamInfo, OutHLSL);
static const TCHAR *FormatDeclarations = TEXT(R&#34;(
Texture3D<float> {GridName};
RWTexture3D<float> RW{OutputGridName};
int3 {NumTiles};
float3 {OneOverNumTiles};
float3 {UnitClampMin};
float3 {UnitClampMax};
SamplerState {SamplerName};
int4 {AttributeIndicesName}[{AttributeInt4Count}];
Buffer<float4> {PerAttributeDataName};
int {NumAttributesName};
int {NumNamedAttributesName};
)&#34;);
// If we use an int array for the attribute indices, the shader compiler will actually use int4 due to the packing rules,
// and leave 3 elements unused. Besides being wasteful, this means that the array we send to the CS would need to be padded,
// which is a hassle. Instead, use int4 explicitly, and access individual components in the generated code.
// Note that we have to have at least one here because hlsl doesn&#39;t support arrays of size 0.
const int AttributeCount = ParamInfo.GeneratedFunctions.Num();
const int AttributeInt4Count = FMath::Max(1, FMath::DivideAndRoundUp(AttributeCount, 4));
TMap<FString, FStringFormatArg> ArgsDeclarations = {
{ TEXT(&#34;GridName&#34;), GridName + ParamInfo.DataInterfaceHLSLSymbol },
{ TEXT(&#34;SamplerName&#34;), SamplerName + ParamInfo.DataInterfaceHLSLSymbol },
{ TEXT(&#34;OutputGridName&#34;), OutputGridName + ParamInfo.DataInterfaceHLSLSymbol },
{ TEXT(&#34;NumTiles&#34;), NumTilesName + ParamInfo.DataInterfaceHLSLSymbol },
{ TEXT(&#34;OneOverNumTiles&#34;), OneOverNumTilesName + ParamInfo.DataInterfaceHLSLSymbol },
{ TEXT(&#34;UnitClampMin&#34;), UnitClampMinName + ParamInfo.DataInterfaceHLSLSymbol },
{ TEXT(&#34;UnitClampMax&#34;), UnitClampMaxName + ParamInfo.DataInterfaceHLSLSymbol },
{ TEXT(&#34;UnitToUVName&#34;), UNiagaraDataInterfaceRWBase::UnitToUVName + ParamInfo.DataInterfaceHLSLSymbol},
{ TEXT(&#34;AttributeIndicesName&#34;), AttributeIndicesBaseName + ParamInfo.DataInterfaceHLSLSymbol},
{ TEXT(&#34;PerAttributeDataName&#34;), PerAttributeDataName + ParamInfo.DataInterfaceHLSLSymbol},
{ TEXT(&#34;AttributeInt4Count&#34;), AttributeInt4Count},
{ TEXT(&#34;NumAttributesName&#34;), UNiagaraDataInterfaceRWBase::NumAttributesName + ParamInfo.DataInterfaceHLSLSymbol},
{ TEXT(&#34;NumNamedAttributesName&#34;), UNiagaraDataInterfaceRWBase::NumNamedAttributesName + ParamInfo.DataInterfaceHLSLSymbol},
};
OutHLSL += FString::Format(FormatDeclarations, ArgsDeclarations);
}
//...
// UNiagaraDataInterfaceGrid3DCollection数据结构的第三类代码生成
bool UNiagaraDataInterfaceGrid3DCollection::GetFunctionHLSL(const FNiagaraDataInterfaceGPUParamInfo& ParamInfo, const FNiagaraDataInterfaceGeneratedFunction& FunctionInfo, int FunctionInstanceIndex, FString& OutHLSL)
{
bool ParentRet = Super::GetFunctionHLSL(ParamInfo, FunctionInfo, FunctionInstanceIndex, OutHLSL);
if (ParentRet)
{
return true;
}
// 这里将所有一般量与其HLSL里面的Symbol联系起来
TMap<FString, FStringFormatArg> ArgsBounds =
{
{TEXT(&#34;FunctionName&#34;), FunctionInfo.InstanceName},
{TEXT(&#34;Grid&#34;), GridName + ParamInfo.DataInterfaceHLSLSymbol},
{TEXT(&#34;OutputGrid&#34;), OutputGridName + ParamInfo.DataInterfaceHLSLSymbol},
{TEXT(&#34;NumAttributes&#34;), UNiagaraDataInterfaceRWBase::NumAttributesName + ParamInfo.DataInterfaceHLSLSymbol},
{TEXT(&#34;NumNamedAttributes&#34;), UNiagaraDataInterfaceRWBase::NumNamedAttributesName + ParamInfo.DataInterfaceHLSLSymbol},
{TEXT(&#34;NumCells&#34;), UNiagaraDataInterfaceRWBase::NumCellsName + ParamInfo.DataInterfaceHLSLSymbol},
{TEXT(&#34;UnitToUVName&#34;), UNiagaraDataInterfaceRWBase::UnitToUVName + ParamInfo.DataInterfaceHLSLSymbol},
{TEXT(&#34;SamplerName&#34;), SamplerName + ParamInfo.DataInterfaceHLSLSymbol},
{TEXT(&#34;NumTiles&#34;), NumTilesName + ParamInfo.DataInterfaceHLSLSymbol},
{TEXT(&#34;OneOverNumTiles&#34;), OneOverNumTilesName + ParamInfo.DataInterfaceHLSLSymbol },
{TEXT(&#34;UnitClampMin&#34;), UnitClampMinName + ParamInfo.DataInterfaceHLSLSymbol },
{TEXT(&#34;UnitClampMax&#34;), UnitClampMaxName + ParamInfo.DataInterfaceHLSLSymbol },
{TEXT(&#34;NumCellsName&#34;), UNiagaraDataInterfaceRWBase::NumCellsName + ParamInfo.DataInterfaceHLSLSymbol},
{TEXT(&#34;PerAttributeDataName&#34;), PerAttributeDataName + ParamInfo.DataInterfaceHLSLSymbol},
{TEXT(&#34;NumAttributesName&#34;), UNiagaraDataInterfaceRWBase::NumAttributesName + ParamInfo.DataInterfaceHLSLSymbol},
};
// 这里用很多if判断函数,然后用打上标记的HLSL代码,在过一趟Format将上面设置的值转换到HLSL代码中
if (FunctionInfo.DefinitionName == GetValueFunctionName || FunctionInfo.DefinitionName == GetPreviousValueAtIndexFunctionName)
{
static const TCHAR *FormatBounds = TEXT(R&#34;(
void {FunctionName}(int In_IndexX, int In_IndexY, int In_IndexZ, int In_AttributeIndex, out float Out_Val)
{
Out_Val = 0;
if ( In_AttributeIndex < {NumAttributesName} )
{
int3 TileOffset = {PerAttributeDataName}.xyz;
Out_Val = {Grid}.Load(int4(In_IndexX + TileOffset.x, In_IndexY + TileOffset.y, In_IndexZ + TileOffset.z, 0));
}
}
)&#34;);
OutHLSL += FString::Format(FormatBounds, ArgsBounds);
return true;
}
else if
//...
else if (FunctionInfo.DefinitionName == GetFloatAttributeIndexFunctionName)
{
WriteAttributeGetIndexHLSL(ParamInfo, FunctionInfo, FunctionInstanceIndex, 1, OutHLSL);
return true;
}
//...
}
非常震撼啊对于我来说,读者可以自行尝试一下Debug开一下,然后断点打在这里,如果错过了断点,要在蓝图中Compile那里点Full Rebuild,可以用查看看得到完整的输出代码(虽然里面部分“\t”没调好没对齐,不过不影响观看),有当年用代码生成代码那味了,这种工作工程量真的都是巨型的。梳理一下查找逻辑,一般将蓝色函数的空格去掉就可以在VS里面搜索,然后会搜索到一个FunctionName,利用这个FName变量再搜索,就可以找到对应的位置了。
在int32 FNiagaraEditorModule::CompileScript打上断点可以得到一个比较完整的HLSL,可惜VS自带的没法直接在里面查看搜索,得复制出来到一个文本浏览器好一些。
对于这里的例子来说,HLSL代码就翻译成为了,可以看出来以下事实
一个Data Interface Grid里面可能装载了不止一个Grid,就等于说SimGrid是Data Interface,而Velocity Grid只是我们虚构出来的一个Grid,他只是作为我们提取出来的一个SimGrid中开始的Index逻辑而存在着,使用的时候是通过这个Index的Offset查询到的数据!这个就有点像OpenGL的EBO那味道。只不过这些步骤不看源码是无法得知的。
当然Module Usage上面有写,不过我觉得这并不是用文字能够解释清楚的一件事情,还是代码来的正常。
This module caches grid attribute indices to emitter parameters. These attributes can then be used to efficiently reference grid data while maintaining a readable format.
Check the module writes to see the attributes.// AttributeIndices_Emitter_SimGrid是预处理过的数值,由CPU传递到GPU
// 值得注意的是,这里的所有函数都需要用CUDA或者Compute Shader的思维来看待
// Link To 1.2.1 ⑥
// 这里就是获取Index,可以看上面的事实有一个大概的印象
void GetVectorAttributeIndex_Emitter_SimGrid_AttributeVelocity(out int Out_Val)
{
int In_AttributeIndex = AttributeIndices_Emitter_SimGrid.z;
Out_Val = In_AttributeIndex;
}
// ExecutionIndexToGridIndex,这个就是类似于CUDA的Index索引提取
// 如果是提供的ThreadIndex非线性模式(通常我比较喜欢这样),就各自赋值,不然就需要反解XYZ
void ExecutionIndexToGridIndex_Emitter_SimGrid(out int Out_IndexX, out int Out_IndexY, out int Out_IndexZ)
{
#if NIAGARA_DISPATCH_TYPE == NIAGARA_DISPATCH_TYPE_THREE_D || NIAGARA_DISPATCH_TYPE == NIAGARA_DISPATCH_TYPE_CUSTOM
Out_IndexX = GDispatchThreadId.x;
Out_IndexY = GDispatchThreadId.y;
Out_IndexZ = GDispatchThreadId.z;
#else
const uint Linear = GLinearThreadId;
Out_IndexX = Linear % NumCells_Emitter_SimGrid.x;
Out_IndexY = (Linear / NumCells_Emitter_SimGrid.x) % NumCells_Emitter_SimGrid.y;
Out_IndexZ = Linear / (NumCells_Emitter_SimGrid.x * NumCells_Emitter_SimGrid.y);
#endif
}
// In_AttributeIndex在使用的时候x轴为VectorIndex+0,y轴为VectorIndex+1,z轴为VectorIndex+2
// 上面赋值完的IndexXYZ、以及GetFloatAttributeIndex的输出就作为这里的输入,在蓝图中也可以看得很清楚这部分逻辑
void GetGridValue_Emitter_SimGrid(int In_IndexX, int In_IndexY, int In_IndexZ, int In_AttributeIndex, out float Out_Val)
{
Out_Val = 0;
if ( In_AttributeIndex < NumAttributes_Emitter_SimGrid )
{
int3 TileOffset = PerAttributeDataName_Emitter_SimGrid.xyz;
Out_Val = Grid_Emitter_SimGrid.Load(int4(
In_IndexX + TileOffset.x, In_IndexY + TileOffset.y,
In_IndexZ + TileOffset.z, 0));
}
}
// 下面两个函数不是这个Module里面使用的,但是可以体现出一些上面说的部分事实,从for可以看出来
void GetPreviousFloatValue_Emitter_TemporaryGrid_AttributeSimFloat(int In_IndexX, int In_IndexY, int In_IndexZ, out float Out_Val)
{
int In_AttributeIndex = AttributeIndices_Emitter_TemporaryGrid.y;
for (int i = 0; i < 1; i++)
{
int CurAttributeIndex = In_AttributeIndex + i;
int3 TileOffset = PerAttributeDataName_Emitter_TemporaryGrid.xyz;
float Val = Grid_Emitter_TemporaryGrid.Load(int4(
In_IndexX + TileOffset.x, In_IndexY + TileOffset.y,
In_IndexZ + TileOffset.z, 0));
Out_Val = Val;
}
}
void GetPreviousVectorValue_Emitter_SimGrid_AttributeVelocity(int In_IndexX, int In_IndexY, int In_IndexZ, out float3 Out_Val)
{
int In_AttributeIndex = AttributeIndices_Emitter_SimGrid.z;
for (int i = 0; i < 3; i++)
{
int CurAttributeIndex = In_AttributeIndex + i;
int3 TileOffset = PerAttributeDataName_Emitter_SimGrid.xyz;
float Val = Grid_Emitter_SimGrid.Load(int4(
In_IndexX + TileOffset.x, In_IndexY + TileOffset.y,
In_IndexZ + TileOffset.z, 0));
Out_Val = Val;
}
}
// 完整的HLSL代码其中的一段,也就是这个Module转化为HLSL的流程代码
// 可以清晰的看出来流程,DI的信息应该就是在Context里面了
// 状态设置->执行ComputeCurl->配置VectorLength->执行VectorLength->配置SetVariables->执行SetVariables
#if ((SimulationStageIndex == 4)) // MapSimStage4_ComputeCurl
void SimulateMapSimStage4_ComputeCurl(inout FSimulationContext Context)
{
//Begin Stage Script: MapSimStage4_ComputeCurl!
Context.MapSimStage4_ComputeCurl.Grid3D_ComputeCurl.dx = Context.MapSimStage4_ComputeCurl.Emitter.dx;
Context.MapSimStage4_ComputeCurl.Grid3D_ComputeCurl.VectorIndex = Context.MapSimStage4_ComputeCurl.Emitter.SimGrid_VelocityIndex;
EnterStatScope(6 /**Grid3D_ComputeCurl_Emitter_Func_*/);
Grid3D_ComputeCurl_Emitter_Func_(Context);
ExitStatScope(/**Grid3D_ComputeCurl_Emitter_Func_*/);
Context.MapSimStage4_ComputeCurl.VectorLength.VECTOR_VAR = Context.MapSimStage4_ComputeCurl.OUTPUT_VAR.Grid3D_ComputeCurl.Curl;
float VectorLength_Emitter_Func_Output_NewOutput;
VectorLength_Emitter_Func_(VectorLength_Emitter_Func_Output_NewOutput, Context);
Context.MapSimStage4_ComputeCurl.SetVariables_6C0313394B4550FBC5D741A95A8D1161.Emitter.TemporaryGrid.SimFloat = VectorLength_Emitter_Func_Output_NewOutput;
EnterStatScope(7 /**SetVariables_6C0313394B4550FBC5D741A95A8D1161_Emitter_Func_*/);
SetVariables_6C0313394B4550FBC5D741A95A8D1161_Emitter_Func_(Context);
ExitStatScope(/**SetVariables_6C0313394B4550FBC5D741A95A8D1161_Emitter_Func_*/);
//End Simulation Stage Script: MapSimStage4_ComputeCurl
}
#endif // MapSimStage4_ComputeCurl这下会不会有豁然开朗的感觉。
②Grid 3D Compute Curl根据计算出来的Curl设置了Velocity的初值,其实这个步骤在上面的代码里面都有了,按照上面的思路,可以调整我们对蓝图节点的思维模式,其实他就是描述了一个除了IndexFetch的Kernel部分。
1.2.5 Pre Sim Velocity
根据上面的分析,下面的过程我们对于其底层运行是有一定认识的,确定性的运行逻辑总是好的!这样我们就可以专注于上层的实现了。这一个部分主要的功能是计算外力。
①Grid 3D Get Transients from Grid Values 002是提取场量到对应的TransientGrid中,ScalarGrid量都是直接用Get Previous <Type> Value实现的,而SimGrid对其封装了一层,是因为SimGrid和ScalarGrid的大小并不是对应的,SimGrid有可能比ScalarGrid小(Default的话是1:1,但是这个可以由一个参数控制,而TransientGrid就是纯纯的1:1了),所以就需要对Get Previous <Type> Value套一层封装,对于ScalarGrid比SimGrid大的地方,就需要补0;
虽然例子中只勾选了SimGrid,但是其中有个Transient Values比较吸引我的注意,Boundary在示例中是Transient的Tag,所以应该是不能获取上一帧的值的,而这里又有一个GetPerviousFloatValue调用了Boundary,所以这里还需要修正一个观念,也就是,Niagara允许在某些情况下变量重名,这样子其实还是蛮容易混淆的,一定要注意TransientGrid与Transient不是同一个概念,Transient量就是一个临时输出,而TransientGrid是我们存放可持久数据的地方。在③中您可能更好理解我所说的含义。
②Grid 3D Compute Boundary这是一个很重要的大家伙,这里输出的Boundary就是Transient,先看一下需要检测的碰撞类型
Grid 3D GAS CONTROLS SPAWN
Grid 3D Compute Boundary Input,从上到下依次为FTFFFT
分析源码可以得到碰撞查询函数为DIRigidMeshCollision_GetClosestPointMeshDistanceFieldNoNormal,也就是下图标注的节点,返回空间SDF值与物体速度,如果空间SDF值小于等于0那么该空间位置就是在需要查询可碰撞的物体内部,也就是这个点是属于边界的,也就有了下图后面的Check以及分支行为。
还有模拟边界的设置,部分模拟边界我们可以认为是Boundary,这个计算也在这个模块中,这样在上面计算Check为False的时候,就可以利用这个部分计算出来的值作为代替(也就是Select的逻辑)。下面这个就是边界设置BoundaryWidth就是我们设置空间中有多少个Cell是用来给边界使用的,Boundary的值有如下的表示
0:表示不是边界,是流体的一个部分。
0-1:表示是固体表面,这个空间不允许流体通过,需要执行第二类边界条件。
0-2:表示是自由表面,对于水来说,他的自由表面边界需要特殊处理,因为自由表面边界属于流体,这就有一个特性,那就是他旁边一半的空间是没有流体的,一半的空间是有流体的,如果按照平时有限差分的方法来计算肯定有一部分就拿不到数据,如果计算类似密度,就会产生错误。这部分边界执行第一类边界条件。
可以看到示例中提供了对模拟环境边界的设置,如果我们认为模拟边界是Boundary,那么就会计算目前处理的Cell在不在BoundaryWidth里面,如果在里面,还需要Check此时要通过的边界是不是Open的,如果是Open的那么场量可以从这个边界流失,需要以自由表面的方式处理(就是上面说明的自由表面的特殊性),否则就直接标记为不可通过。
// 部分源码,因为涉及的东西太多,看一下就好,大概清楚里面的逻辑
// NiagaraDataInterfaceRigidMeshCollisionQuery.ush
// Given a world space position (WorldPosition) compute the sphere closest point (position,normal,velocity)
int DIRigidMeshCollision_GetClosestElement(in FDIRigidMeshCollisionContext DIContext, in float3 WorldPosition, out float3 OutClosestPosition,
out float3 OutClosestNormal, out float OutMinDistance, in float TimeFraction)
{
float MinDistance = MAX_DISTANCE;
int ElementIndex = -1;
float3 CollisionPosition = float3(0,0,0);
float3 CollisionNormal = float3(0,0,0);
const int SpheresBegin = DIContext.ElementOffsets;
const int SpheresEnd = DIContext.ElementOffsets;
for (int SphereIndex = SpheresBegin; SphereIndex < SpheresEnd; ++SphereIndex)
{
const float3 LocalPosition = DIRigidMeshCollision_GetLocalPosition(DIContext,WorldPosition,SphereIndex,TimeFraction);
DIRigidMeshCollision_GetSphereProjection(LocalPosition, float3(0,0,0), DIContext.ElementExtentBuffer.x, SphereIndex,
CollisionPosition, CollisionNormal, ElementIndex, MinDistance);
}
const int CapsulesBegin = DIContext.ElementOffsets;
const int CapsulesEnd = DIContext.ElementOffsets;
for (int CapsuleIndex = CapsulesBegin; CapsuleIndex < CapsulesEnd; ++CapsuleIndex)
{
const float3 LocalPosition = DIRigidMeshCollision_GetLocalPosition(DIContext,WorldPosition,CapsuleIndex,TimeFraction);
DIRigidMeshCollision_GetCapsuleProjection(LocalPosition, DIContext.ElementExtentBuffer.xy, CapsuleIndex,
CollisionPosition, CollisionNormal, ElementIndex, MinDistance);
}
const int BoxesBegin = DIContext.ElementOffsets;
const int BoxesEnd = DIContext.ElementOffsets;
for (int BoxIndex = BoxesBegin; BoxIndex < BoxesEnd; ++BoxIndex)
{
const float3 LocalPosition = DIRigidMeshCollision_GetLocalPosition(DIContext,WorldPosition,BoxIndex,TimeFraction);
DIRigidMeshCollision_GetBoxProjection(LocalPosition, DIContext.ElementExtentBuffer.xyz, BoxIndex,
CollisionPosition, CollisionNormal, ElementIndex, MinDistance);
}
OutClosestPosition = CollisionPosition;
OutClosestNormal = CollisionNormal;
OutMinDistance = MinDistance;
return ElementIndex;
}
// Given a world space position (WorldPosition) compute the static mesh closest point (position,normal,velocity)
void DIRigidMeshCollision_GetClosestPointMeshDistanceFieldNoNormal(in FDIRigidMeshCollisionContext DIContext, in float3 WorldPosition, in float DeltaTime, in float TimeFraction, out float OutClosestDistance, out float3 OutClosestPosition, out float3 OutClosestVelocity)
{
float3 CollisionPosition = float3(0,0,0);
float3 CollisionNormal = float3(0,0,0);
OutClosestDistance = MAX_DISTANCE;
OutClosestPosition = float3(0,0,0);
OutClosestVelocity = float3(0,0,0);
float OutClosestDistanceTmp;
const int ElementIndex = DIRigidMeshCollision_GetClosestElement(DIContext,WorldPosition,CollisionPosition,CollisionNormal,OutClosestDistanceTmp,TimeFraction);
if (ElementIndex != -1)
{
int DFIndex = DIContext.DFIndexBuffer;
if (DFIndex >= 0 && DFIndex < NumSceneObjects)
{
// #todo(dmp): this does a dynamic branch based on intersecting the bbox.Maybe we can factor that out due to the broadphase here?
OutClosestDistance = DistanceToNearestSurfaceForObject(DFIndex, WorldPosition, 1e-3);
const float3 PreviousPosition = mul(DIRigidMeshCollision_GetPreviousTransform(DIContext,ElementIndex), float4(CollisionPosition,1.0)).xyz;
const float3 CurrentPosition = mul(DIRigidMeshCollision_GetCurrentTransform(DIContext,ElementIndex), float4(CollisionPosition,1.0)).xyz;
OutClosestVelocity = ( CurrentPosition - PreviousPosition ) / DeltaTime;
OutClosestPosition = PreviousPosition + TimeFraction * (CurrentPosition-PreviousPosition);
}
}
}
③Grid 3D Set Boundary Grid Values将上面计算出来的Transient Boundary临时输出赋值到TransientGrid的管理Boundary的那部分内存中(希望你们能看懂这句话,因为在前面也提醒过很多次Transient与TransientGrid的区别)。并且将求出来的固体速度也放到TransientGrid的SolidVelocity中。给后面计算流体提供了充足的边界数据。
④Grid 3D Gas Buoyancy提供了计算浮力的方法,浮力并不难,初中都学过,主要是其中参数的配置,因为烟雾上飘的主要原因是,烟其实是固体,是在下降的,而其热扩散加热了周围的空气,这个效应形成了低压区,固体颗粒的收尾速度小于上升气流速度,整体就呈现向上飘的形态了。但是基本上,模拟的时候不会这样考虑,而是将温度、密度乘以他们对浮力影响的系数。整体就是一个简化模型,最后将Force加到Physics Force上面。
当然,这个模型应该还可以更加高级才对,这个系数不应该是HardCode的,应该与烟属性有关。
⑤Grid 3D Compute Gradient 001&Grid 3D Compute Curl 001&Grid 3D Vorticity Confinement Force
这个三个过程结合起来就是现在标准的Artificial Force方法,在1.2.4里面计算出来的速度旋度就用在了这里,就是一个计算公式,其中 |\mathbf{u}|_{i,j} = length(\mathbf{u}_{i,j}) ,其他的数学表示可以参考之前写的基础知识与基础方法实现
\begin{align} \omega &= \nabla \times \mathbf{u} \tag{Grid 3D Compute Curl}\\ \eta &= \nabla\left|\omega\right|\tag{Grid 3D Compute Gradient 001} \\\omega &= \nabla \times \mathbf{u} \tag{Grid 3D Compute Curl 001}\\ \mathbf{f}_{conf} &= \epsilon \Delta x\left(\frac{\eta}{\left|\eta\right|}\times\omega\right) \tag{Grid 3D Vorticity Confinement Force} \end{align}\\是的,你没看错,旋度就是求了两次,可能后面有需要用到的地方吧,如果没有,那么这显然是一个错误的设计,但这里也看出来了,Grid保存的数据必须要Fetch出来才能用,如果没Fetch成为Transient(Output),那么函数就至少需要传递Index用来Fetch,部分函数的输入是Transient(Output),部分函数的输入是Grid,所以特别要注意。
⑥Grid 3D Resample Float 003/004&Grid 3D Turbulence 001/002
这先是一个Resample,不知道是不是这个系统具有的一个特性,后面Grid 3D Turbulence 001/002我们的一些计算需要用SimGrid,而亟待使用的Temperature与Density现在都是在ScalarGrid中而且他们的输入需要是Transient(Output),并且没有Index接口,所以在使用Grid 3D Turbulence之前就需要进行一次Resample,把需要使用的数据Fetch出来。
Grid 3D Turbulence 001/002和之前的计算Turbulence一样,不过这次多了Density与Temperature的影响,具体可以看下蓝图节点的参数设置。
⑦Grid 3D Wind计算风力,其中有一个Local开关,就是说目前输入的风力信息是局部坐标系还是世界坐标系,注意一下这个就行,如果打开了Local,这个力的累积就不会到PhysicsForce,而是到PhysicsForceLocal,这个在后面的积分可以看见。
⑧Grid 3D Integrate Forces将上面的力进行时间积分,其实就是将PhysicsForce转换到局部坐标与PhysicsForceLocal相加,再乘以 \Delta t 就变成了速度变化量。
⑨Set Velocity把上面计算出来的新速度放回到Grid中。
注:这里需要放回去,就是因为Transient(Output)类型不能跨Stage,那么如果要下面的Stage能够使用新的Velocity,就需要这里赋值到Grid中,然后在后面使用的时候拿Grid和Index再拿出来。
到这里我们其实已经窥探到很多Niagara的设计思想了,但是个人觉得,这个玩意怎么说呢?论简易性,我觉得物理模拟做的很简易是不太可行的,至少任何的扩展都不会变得非常简易;论效率,这样的设计效率肯定没有精心设计的Pipeline效率高,重复的Load与Store肯定会存在一定的消耗。
1.2.6 Sourcing
①Grid 3D Get Vector就是将前面的Velocity取出来,里面是用的Index,所以就没法用Get Previous Vector Value了,按理来说即使是General Function应该做个用Index索引的Get Vector也不是什么难事吧,怪了。现在Transient就有了一个Velocity的副本VectorValue。
②Set三个Transient变量,这里Velocity的设置就用了刚刚获取VectorValue,但是上面两个ScalarGrid由不需要先提取一次,好奇心让我直接把Velocity的赋值改成了SimGrid Tag的Velocity,果然报错了
Variable Emitter.SimGrid.Velocity was read before being set. It&#39;s default mode is &#34;Fail If Previously Not Set&#34;, so this isn&#39;t allowed. - Node: Map Get -Grid3D_Gas_Controls_Base_Emitter, Particle Simulation Stage Script, 盲猜一波Stage Settings的锅,果然将这个Stage改成了SimGrid立马另外两个就报错了,唉,好奇怪的设计(过段时间看一下,这个部分应该与其Compile与HLSL Gen有关),所以这里为了让Scalar Grid用起来方便,就行库了基于SimGrid的Velocity,③中给出了更加详细的分析。
③Grid 3D Gas Init是用来给源头提供“虚空物品”的东西,其主体控制逻辑在后面(有几个与效果有关的控制参数,到这个部分读者应该可以自行看懂),前面先采样了一下PerlinNoise,利用采样到的Noise在Source部分制造噪声,让Source不均匀得奇怪。中间有一步很迷,他把Velocity、Temperature、Density的赋值分开了,但是我自己觉得应该能合并,实际上我把两个合并之后,也是正确运行的。由此,我大概能理解,他可能是觉得Velocity和前面不一样,但是在一个Stage中,给的Index都是一样的,所以,如果真的要按Grid分开设计,那么的确每个Stage只能处理一种Grid,显然他们自己也没有贯彻自己的设计哲学,我都想帮帮他,可惜我不太认可这种设计哲学,哈哈。下面代码不复杂,理解一下逻辑很容易懂。
这个部分阐述了这样一个事实,与Data Interface匹配的Grid,HLSL生成器会自动Load与Store,这个操作是访问GPU GlobalMemory很慢,所以中间直接使用的量是Cache在Context中的数据,更快,而对于Velocity和他们不一样,Velocity在Stage中间做Get、Set就需要调用Load与Store,但又由于Transient Tag量的客观存在,所以其实。。。我沉默了。
// HLSL中部分源码,可以看出来这里ScalarGrid和SimGrid的处理完全不一样,这里展示一部分与初始化有关的
// Load与Store中,自动执行Grid的部分之后与DataInterface相同的部分。
void SimulateMainComputeCS(
uint3 DispatchThreadId : SV_DispatchThreadID,
uint3 GroupThreadId : SV_GroupThreadID) {
//...
InitConstants(Context);
// 内部执行SetupFromIterationSource_MapSimStage6_Sourcing(Context);
LoadUpdateVariables(Context, GLinearThreadId);
//...
// 执行Sourcing
SimulateMapSimStage6_Sourcing(Context);
//...
// 更新数据
// 内部执行TeardownFromIterationSource_MapSimStage6_Sourcing(Context);
StoreUpdateVariables(Context, bRunUpdateLogic || bRunSpawnLogic);
}
// 初始化
// 可以看见这里对SimGrid和ScalarGrid的态度完全不一样,Context现在有ScalarGrid字段
// Niagara自动将Tag为ScalarGrid并且需要使用的Index放在Context中,就有了②中差异化的原因。
void InitConstants(inout FSimulationContext Context) {
//...
#if ((SimulationStageIndex == 6)) // MapSimStage6_Sourcing
Context.MapSimStage6_Sourcing.Emitter.SimGrid_VelocityIndex = Emitter_SimGrid_VelocityIndex;
Context.MapSimStage6_Sourcing.Emitter.ScalarGrid.Temperature = Emitter_ScalarGrid_Temperature;
Context.MapSimStage6_Sourcing.Emitter.Grid3D_GAS_CONTROLS_SPAWN.DebugSources = Emitter_Grid3D_GAS_CONTROLS_SPAWN_DebugSources;
Context.MapSimStage6_Sourcing.Emitter.ScalarGrid.Density = Emitter_ScalarGrid_Density;
Context.MapSimStage6_Sourcing.Emitter.Grid3D_CreateUnitToWorldTransform.UnitToWorld = Emitter_Grid3D_CreateUnitToWorldTransform_UnitToWorld;
Context.MapSimStage6_Sourcing.Emitter.Grid3D_CreateUnitToWorldTransform.WorldToLocal = Emitter_Grid3D_CreateUnitToWorldTransform_WorldToLocal;
Context.MapSimStage6_Sourcing.Emitter.dt = Emitter_dt;
Context.MapSimStage6_Sourcing.Emitter.Age = Emitter_Age;
#endif // MapSimStage6_Sourcing
//...
}
// 在全局Load,PerAttributeDataName_Emitter_ScalarGrid -> Grid_Emitter_ScalarGrid
// 在全局Store,Grid_Emitter_ScalarGrid -> RWOutputGrid_Emitter_ScalarGrid
// 中间参与计算都是 Grid_Emitter_ScalarGrid。
Texture3D<float> Grid_Emitter_ScalarGrid;
RWTexture3D<float> RWOutputGrid_Emitter_ScalarGrid;
Buffer<float4> PerAttributeDataName_Emitter_ScalarGrid;
// Velocity Set的计算
void SetGridValue_Emitter_SimGrid(int In_IndexX, int In_IndexY, int In_IndexZ, int In_AttributeIndex, float In_Value, out int val){
val = 0;
if ( In_AttributeIndex < NumAttributes_Emitter_SimGrid ){
int3 TileOffset = PerAttributeDataName_Emitter_SimGrid.xyz;
RWOutputGrid_Emitter_SimGrid = In_Value;
}
}
// -------------------------------LoadUpdateVariables(Context, GLinearThreadId)---Begin--------------------
void GetPreviousFloatValue_Emitter_ScalarGrid_AttributeDensity(int In_IndexX, int In_IndexY, int In_IndexZ, out float Out_Val){
int In_AttributeIndex = AttributeIndices_Emitter_ScalarGrid.z;
for (int i = 0; i < 1; i++){
int CurAttributeIndex = In_AttributeIndex + i;
nt3 TileOffset = PerAttributeDataName_Emitter_ScalarGrid.xyz;
float Val = Grid_Emitter_ScalarGrid.Load(int4(In_IndexX + TileOffset.x, In_IndexY + TileOffset.y, In_IndexZ + TileOffset.z, 0));
Out_Val = Val;
}
}
#if ((SimulationStageIndex == 6)) // MapSimStage6_Sourcing
void SetupFromIterationSource_MapSimStage6_Sourcing_GeneratedSetup_Func_(inout FSimulationContext Context){
}
void SetupFromIterationSource_MapSimStage6_Sourcing_GeneratedReadAttributesEmitter_ScalarGrid_Func_(inout FSimulationContext Context){
//Generated by UNiagaraDataInterfaceGrid3DCollection::GenerateIterationSourceNamespaceReadAttributesHLSL
// Argument Name &#34;Map&#34; Type &#34;NiagaraParameterMap&#34;
// Argument Name &#34;TargetDataInterface&#34; Type &#34;NiagaraDataInterfaceGrid3DCollection&#34;
int X, Y, Z;
ExecutionIndexToGridIndex_Emitter_ScalarGrid(X, Y, Z);
// Variable Name &#34;Emitter.ScalarGrid.Temperature&#34; Type &#34;NiagaraFloat&#34; Var &#34;Map.Emitter.ScalarGrid.Temperature&#34;
GetPreviousFloatValue_Emitter_ScalarGrid_AttributeTemperature(X, Y, Z, Context.MapSimStage6_Sourcing.Emitter.ScalarGrid.Temperature);
// Variable Name &#34;Emitter.ScalarGrid.Density&#34; Type &#34;NiagaraFloat&#34; Var &#34;Map.Emitter.ScalarGrid.Density&#34;
GetPreviousFloatValue_Emitter_ScalarGrid_AttributeDensity(X, Y, Z, Context.MapSimStage6_Sourcing.Emitter.ScalarGrid.Density);
}
void SetupFromIterationSource_MapSimStage6_Sourcing(inout FSimulationContext Context) {
SetupFromIterationSource_MapSimStage6_Sourcing_GeneratedSetup_Func_(Context);
SetupFromIterationSource_MapSimStage6_Sourcing_GeneratedReadAttributesEmitter_ScalarGrid_Func_(Context);
}
// -------------------------------LoadUpdateVariables(Context, GLinearThreadId)---End--------------------
// ------------StoreUpdateVariables(Context, bRunUpdateLogic || bRunSpawnLogic)---Begin--------------------
void CopyMaskedPreviousToCurrentForCell_Emitter_ScalarGrid_UEImpureCall(int In_IndexX, int In_IndexY, int In_IndexZ, int NumAttributesSet, int AttributeMask){
// early out if we set all the attributes that exist on the DI
if (NumAttributesSet == NumAttributes_Emitter_ScalarGrid.x){
return;
}
for (int AttributeIndex = 0; AttributeIndex < NumAttributes_Emitter_ScalarGrid.x; AttributeIndex++){
// check the attribute index in the attribute mask to see if it has been set
// we automatically pass through attributes higher than 31 since we can only
// store 32 attributes in the mask
if ((AttributeMask & (1 << AttributeIndex)) == 0 || AttributeIndex >= 32){
int3 TileOffset = PerAttributeDataName_Emitter_ScalarGrid.xyz;
float Val = Grid_Emitter_ScalarGrid.Load(int4(In_IndexX + TileOffset.x, In_IndexY + TileOffset.y, In_IndexZ + TileOffset.z, 0));
RWOutputGrid_Emitter_ScalarGrid = Val;
}
}
}
void TeardownFromIterationSource_MapSimStage6_Sourcing_GeneratedTeardown_Func_(inout FSimulationContext Context){
//Generated by UNiagaraDataInterfaceGrid3DCollection::GenerateTeardownHLSL
}
void TeardownFromIterationSource_MapSimStage6_Sourcing_GeneratedWriteAttributesEmitter_ScalarGrid_Func_(inout FSimulationContext Context){
//Generated by UNiagaraDataInterfaceGrid3DCollection::GenerateIterationSourceNamespaceWriteAttributesHLSL
// Argument Name &#34;Map&#34; Type &#34;NiagaraParameterMap&#34;
// Argument Name &#34;TargetDataInterface&#34; Type &#34;NiagaraDataInterfaceGrid3DCollection&#34;
int AttributeIsSetMask = 0;
int CurrAttributeIndex;
int X, Y, Z;
ExecutionIndexToGridIndex_Emitter_ScalarGrid(X, Y, Z);
// Name &#34;Emitter.ScalarGrid.Temperature&#34; Type &#34;NiagaraFloat&#34; Var &#34;Map.Emitter.ScalarGrid.Temperature&#34;
GetFloatAttributeIndex_Emitter_ScalarGrid_AttributeTemperature(CurrAttributeIndex);
AttributeIsSetMask |= 1 << (CurrAttributeIndex+0);
SetFloatValue_Emitter_ScalarGrid_UEImpureCall_AttributeTemperature(X, Y, Z,Context.MapSimStage6_Sourcing.Emitter.ScalarGrid.Temperature);
// Name &#34;Emitter.ScalarGrid.Density&#34; Type &#34;NiagaraFloat&#34; Var &#34;Map.Emitter.ScalarGrid.Density&#34;
GetFloatAttributeIndex_Emitter_ScalarGrid_AttributeDensity(CurrAttributeIndex);
AttributeIsSetMask |= 1 << (CurrAttributeIndex+0);
SetFloatValue_Emitter_ScalarGrid_UEImpureCall_AttributeDensity(X, Y, Z,Context.MapSimStage6_Sourcing.Emitter.ScalarGrid.Density);
CopyMaskedPreviousToCurrentForCell_Emitter_ScalarGrid_UEImpureCall(X,Y,Z,2,AttributeIsSetMask);
}
void TeardownFromIterationSource_MapSimStage6_Sourcing(inout FSimulationContext Context){
TeardownFromIterationSource_MapSimStage6_Sourcing_GeneratedTeardown_Func_(Context);
TeardownFromIterationSource_MapSimStage6_Sourcing_GeneratedWriteAttributesEmitter_ScalarGrid_Func_(Context);
}
// ------------StoreUpdateVariables(Context, bRunUpdateLogic || bRunSpawnLogic)---End--------------------
④Set&Grid 3D Set Vector Value 001将临时输出写回,上面呈现的SetGridValue_Emitter_SimGrid就是最主要的操作。
1.2.7 Advect Velocity
差不多从这里开始,就进入经典的网格流体模拟计算方法了,部分Get、Set以及与之前理论基础有关就不会多说明,基本上可以和我们之前的教程对应上。不过这个模块在我的电脑上部分是没法编译的(这玩意不走那边的话都是可以生成HLSL的好像)
Function call &#34;SampleGrid&#34; is not allowed for stack context ENiagaraScriptUsage::Module. Allowed: ENiagaraScriptUsage::ParticleSpawnScript, ENiagaraScriptUsage::ParticleUpdateScript, ENiagaraScriptUsage::ParticleEventScript, ENiagaraScriptUsage::ParticleSimulationStageScript - 还有RK2、RK3尼玛是一样的代码,我也是佩服,应该是没开发了。
1.2.8 Compute Divergence
很简单的计算散度,装到SimFloat里面,这是一个Temporary Grid。再用一个Set清零Pressure。
1.2.9 Solve Pressure
大的来了,这个部分是利用泊松方程解压强,首先Stage Settings就不一样,这个Stage存在迭代次数设置。
在计算之前,获取了Boundary、Pressure、Velocity、Divergence,Solve的时候Use Solid Velocity是True,但是我楞没看出来Boundary处理的时候是怎么更新这个的,甚至Output连Soild Velocity都不见了。有点小破防,暂时先不管Soild的速度(但是更破防的是,我测试了一下,Soild有可能在通过流体的时候,固体身上某些部分变成Source一样的效果),先看下怎么解的吧
Pressure = 0; // Output
#if GPU_SIMULATION
constint FLUID_CELL = 0;
constint SOLID_CELL = 1;
constint EMPTY_CELL = 2;
int CellType = round(B_center); // Input:B_center 这个点的Boundary
if (CellType == FLUID_CELL) // 我们当前处理的Index对应的点,是Fluid
{
float Scale = dx / dt;
int FluidCellCount = 6;
float BoundaryAdd = 0.0;
float Weight;
if (Relaxation < 1e-8) // 如果不选择松弛
{
// same as regular jacobi
Weight = 1;
}
else
{
int SliceParity = (IndexZ + IterationIndex) % 2;
int RowParity = (IndexY + SliceParity+1) % 2;
int CellParity = (IndexX + RowParity ) % 2;
// will do red-black SOR
// add 1 since we want to expose a 0-1 parameter
Weight = CellParity * min(1.93, Relaxation + 1);
}
// 六个方向都计算一次
int CellType_right/up/front = round(B_right/up/front);
if (CellType_right/up/front == SOLID_CELL)
{
FluidCellCount--;
BoundaryAdd += Scale * (Velocity.x/y/z - SV_x_right/up/front);
P_right/up/front = 0;
}
else if (CellType_right/up/front == EMPTY_CELL)
{
P_right/up/front = 0;
}
int CellType_left/down/back = round(B_left/down/back);
if (CellType_left/down/back == SOLID_CELL)
{
FluidCellCount--;
BoundaryAdd -= Scale * (Velocity.x/y/z - SV_x_left/down/back);
P_left/down/back = 0;
}
else if (CellType_left/down/back == EMPTY_CELL)
{
P_left/down/back = 0;
}
// 这个例子里面并没有考虑Surface
if (FluidCellCount > 0)
{
float JacobiPressure = (P_right + P_left + P_up + P_down + P_front + P_back -dx * dx * Divergence / dt + BoundaryAdd) / FluidCellCount;
Pressure = (1.f - Weight) * P_center + Weight * JacobiPressure;
}
}
#endif
为什么会有DeltaT呢?我百思不得其解,根据自己浅薄的流体模拟知识,不足以能够让我想明白这个问题。但如果一定要引进DeltaT,按照里面的说明,以及我现有的知识来说,至少在推导的时候用的是Forward的方式,也就是用时间积分的视角去看,那就应该是基于下面的方程,而不是基于投影
\frac{\partial \omega}{\partial t} = -\frac{1}{\rho}\nabla p \\
可是这么计算 \rho 去哪里了呢?我真的百思不得其解,后面才知道为什么,那么先往下看。
1.2.10 Project Pressure
按照之前的理论,在Project之前要将Pressure的梯度计算出来,然后用目前的速度减去Pressure的梯度,剩下的结果就是一个无散度场。
在做Projection的时候只有上面那几个计算,这就是最坑爹的地方,里面有Density,然后我就以为在Projection的时候用了Density但是前面迭代没用Density,以为是什么神奇的东西,弄了好久才发现,里面这个Density打的是Local,而不是Input,所以其实就是1。而和我们之前的区别就是,一个DeltaT的Scale,所以按理来说,我们之前的计算方法应该是更合理的。
通过修改BoundaryAdd的Scale以及JacobiPressure里面的dt,还有Projection里面的计算都去除,发现结果还是正确的。
还有一个我不太了解的点,可能是部分Coupling的通用方法,就是最后Projection的时候需要把固体速度给流体的思路,以及之前BoundaryAdd的正确性。更新:这个地方其实是完全仿照的里面的计算方法,这本教材在上一篇文章中有链接,BoundaryAdd是书中提出的一个Ghost Pressure,思想是可以将Soild通过近似让其属性可以放在流体框架中直接用。
最后有个Dissipate,也就是人工给一个衰减,这个我其实是不太推荐的,但是对应源头不断产生,这个做法是可以接受的。
1.2.11 Advect Scalars
这里面对Density与Temperature做了Advection,这里面的RK2和RK3就是正确的了,为啥上面Advect Vector是错的。同样这里也做了人工Dissipate。
1.2.12 Debug Slice Prep
这个模块Generic Simulation Stage Settings里面有个Enable Binding比较新,这是控制这个模块是否执行的一个地方,除了手动点击开关,还可以用Attribute控制,这个RenderDebugSlice也可以控制下面Render的开关,所以在Grid 3D GAS CONTROLS SPAWN中,开关Render Debug Slice可以控制这里的计算,也可以控制Render的计算,开启置RenderDebugSlice为True,计算Debug Slice Prep,并且执行第二个Mesh Render,不然RenderBeauty为True,计算Post Sim Scalars,并且执行第一个Mesh Render。
输入的Bool为RenderDebugSlice
Resample前面出现过,但是这里Index用了Embedded的数字,按理来说应该要写Attribute形式的SimGrid_DensityIndex以及SimGrid_TemperatureIndex。
Grid 3D Set RTValues 002有点意思,新的程序设计方式出现了!我第一次看人都有点麻,其实整体是不复杂的,只是表现方式有点问题。这个主要是将现在有的参数放到Render Target(SimRT)中,其中Red、Green、Blue分别存储Density、Temperature、前面两个相加。
1.2.13 Computing Lighting
在Grid 3D GAS CONTROLS UPDATE设置了Render的参数以及Lighting的参数,下面是为渲染计算透光率的Code,不过不知道这是经验方法还是有物理依据的方法。
// 此时的光照强度作为初始的透光率
Transmittance = LightIntensity;
#if GPU_SIMULATION
// quality of 1 means we have 1 step per cell
// 设置RayMarching的StepSize,这个和dx有关,所以在Scalability修改的时候渲染也会变差
float StepSize = dx / RenderQuality;
float WorldStepSize = StepSize;
float t = 0;
// Make sure we never take more than some max number of steps
// 计算需要的Step,这里tmax是WorldGridExtents计算出来的
int NumStepsForRay = min(MaxNumSteps, tmax / StepSize);
// 计算系数
DensityMult *= WorldStepSize;
for (int i = 0; i < NumStepsForRay && Transmittance > 1e-5; ++i)
{
float3 CurrPos = RayStart + RayDir * t;
// Convert local space to unit (0-1)
float3 CurrUnit = CurrPos / WorldGridExtents + .5;
// 采样CurrUnit位置的Density,Grid是ScalarGrid
float Density = 0;
Grid.SampleGrid(CurrUnit.x, CurrUnit.y, CurrUnit.z, DensityIndex, Density);
if( UseLinearCurve || UseDensityCurve )
{
float remap = saturate((Density - DensityCurveOffset) / DensityCurveRange);
if( UseLinearCurve )
{
Density = remap;
}
else
{
DensityCurve.SampleCurve(remap, Density);
}
}
// 结果叠加到Transmittance
Transmittance *= exp(-1. * Density * DensityMult);
t += StepSize;
}
#endif
1.2.14 Post Sim Scalars
这个模块就是数据获取,也把数据放到SimRT上面去,不过RGBA放的东西和之前Debug Slice Prep不一样,分别是Density、Light1、Temperature、Light2。Light1与Light2是Computing Lighting计算出来的两个光源各自的透光度Texture。
1.3.1 Render
双击Mesh Render可以看见其Override Material里面MI_RayMarch_Fire_Ramps,下面是Material中的计算方法。
// Include /Plugin/FX/NiagaraFluids/NiagaraFluids.ush
#define MARCH_SINGLE_RAY_EMISSION(RayStart, RayDir, UnitStepSize, LocalStepSize, NumSteps, Volume, RetV) \
RetV = float4(0,0,0,0); \ // 初始化返回值
float Transmittance = 1; \ // 初始化透光率
int Steps = ceil(NumSteps); \ // 计算需要的Step
if(Steps >= 1.0 ) Steps += 1.0; \ // 因为上面是Ceil,这里稳妥就加了一格
\
for (int i = 0; i < Steps && Transmittance > 1e-5; i++) \ // 循环
{ \
float Extinction; \
float Scattering; \
float3 Luminance; \
float EmissionExtinction = 0; \ // 初始化
float3 EmissionColor = float3(0,0,0); \ // 初始化计算颜色
// Volume结构体的函数,计算出
Volume.ComputeStep(RayStart, RayDir, Extinction, Scattering, Luminance, EmissionExtinction, EmissionColor); \
float ClampedEmissionExtinction = max(EmissionExtinction, 1e-6); \ //
float StepEmissionTransmittance = exp(-EmissionExtinction * LocalStepSize); \ //
// 自发光颜色累加
RetV.rgb += Transmittance * (EmissionColor - EmissionColor * StepEmissionTransmittance) / ClampedEmissionExtinction; \
// 透光率累乘
Transmittance *= StepEmissionTransmittance; \
// 散射累乘
Luminance *= Scattering; \
float ClampedExtinction = max(Extinction, 1e-6); \
float StepTransmittance = exp(-Extinction * LocalStepSize); \
// 散射颜色累加
RetV.rgb += Transmittance * (Luminance - Luminance * StepTransmittance) / ClampedExtinction; \
Transmittance *= StepTransmittance; \
if(i == Steps-2) UnitStepSize, LocalStepSize *= frac(NumSteps); \ //还有这种语法?这应该就是后面的减少Ray的速度
RayStart += RayDir * UnitStepSize; \
} \
RetV.a = 1. - Transmittance; //将透光率作用到结果上
// ------------------------Start Material----------------------
struct FireRampsRenderVolume
{
Texture3D VolumeTexture;
SamplerState VolumeTextureSampler;
float Albedo;
float3 SunColor;
float3 FillColor;
float ConstantExtinction;
float DensityOffset;
float DensityRange;
Texture2D DensityRamp;
SamplerState DensityRampSampler;
float DensityGain;
float TemperatureOffset;
float TemperatureRange;
Texture2D TemperatureRamp;
SamplerState TemperatureRampSampler;
float TemperatureGain;
float FireOpacityGain;
float UseBlackbodyCurve;
float UseDensityCurve;
float UseColorTexture;
Texture3D ColorTexture;
SamplerState ColorTextureSampler;
// Computes volume extinction coefficient and luminance for a given point in the volume
void ComputeStep(
float3 Position, float3 RayDirection, out float OutExtinction,
out float OutScattering, out float3 OutLuminance,
out float OutEmissionExtinction , out float3 OutEmissionColor)
{
// Position是Ray目前March到的位置,
// VolumeTexture就是Post Sim Scalars存储的RimRT,r就是Density
float4 VolumeSample = VolumeTexture.SampleLevel(VolumeTextureSampler, Position, 0);
// 重新映射
float RemapDensity = DensityRange > 1e-8 ? saturate((VolumeSample.r - DensityOffset) / DensityRange) : 0.0;
float DensityCurve = RemapDensity;
// 使用Curve再映射一次
if (UseDensityCurve)
{
DensityCurve = DensityRamp.SampleLevel(DensityRampSampler, RemapDensity, 0 ).r;
}
// 计算烟雾的,Density重映射之后的最终值乘以强度系数
float SmokeExtinction = DensityCurve * DensityGain;
OutExtinction = DensityCurve * DensityGain;
OutExtinction += ConstantExtinction * (OutExtinction > 1e-5);
// 计算颜色,Light1的透光率控制SunColor,Light2的透光率控制FillColor,Color都是Light的参数
OutLuminance = SunColor * VolumeSample.g;
OutLuminance += FillColor * VolumeSample.a;
// 这个应该就是可以控制烟雾颜色的玩意,Niagara示例中带颜色的烟雾可能就是这个实现的
if (UseColorTexture)
{
float3 ColorSample = ColorTexture.SampleLevel(ColorTextureSampler, Position, 0);
OutLuminance *= ColorSample;
}
OutScattering = OutExtinction * Albedo;
// 重映射Temperature
float RemapTemperature = TemperatureRange > 1e-8 ? saturate((VolumeSample.b - TemperatureOffset) / TemperatureRange) : 0;
float4 TemperatureCurve = TemperatureRamp.SampleLevel(TemperatureRampSampler, RemapTemperature, 0 );
// 初始化自发光
OutEmissionExtinction = TemperatureCurve.a * FireOpacityGain;
// 使用黑体辐射
if (UseBlackbodyCurve)
{
const float MaxKelvin = 4500;
OutEmissionColor = MaterialExpressionBlackBody(RemapTemperature * MaxKelvin) * TemperatureGain;
}
else
{
OutEmissionColor = TemperatureCurve.rgb * TemperatureGain;
}
}
};
FireRampsRenderVolume Volume;
Volume.VolumeTexture = VolumeTexture;
Volume.VolumeTextureSampler = VolumeTextureSampler;
Volume.Albedo = Albedo;
Volume.SunColor = SunColor;
Volume.FillColor = FillColor;
Volume.ConstantExtinction = ConstantExtinction.r;
Volume.DensityOffset = DensityOffset;
Volume.DensityRange = DensityRange;
Volume.DensityRamp = DensityRamp;
Volume.DensityRampSampler = DensityRampSampler;
Volume.DensityGain = DensityGain;
Volume.TemperatureOffset = TemperatureOffset;
Volume.TemperatureRange = TemperatureRange;
Volume.TemperatureRamp = TemperatureRamp;
Volume.TemperatureRampSampler = TemperatureRampSampler;
Volume.TemperatureGain = TemperatureGain;
Volume.FireOpacityGain = FireOpacityGain;
Volume.UseBlackbodyCurve = UseBlackbodyCurve;
Volume.UseDensityCurve = UseDensityCurve;
Volume.UseColorTexture = UseColorTexture;
Volume.ColorTexture = ColorTexture;
Volume.ColorTextureSampler = ColorTextureSampler;
float4 ret;
MARCH_SINGLE_RAY_EMISSION(RayStart, RayDir, UnitStepSize, LocalStepSize, NumSteps, Volume, ret);
return ret;
这样就计算完了。
注:
下篇应该还会分析一个粒子方法,再做总结,下篇提供的Niagara基础可能就会少很多了。
参考
[*]^Visual simulation of smokehttps://dl.acm.org/doi/abs/10.1145/383259.383260
[*]^Smoke Simulation for Fire Engineering using a Multigrid Method on Graphics Hardwarehttp://diglib.eg.org/handle/10.2312/PE.vriphys.vriphys09.011-020
[*]^Fluid Simulation for Computer Graphics, Second Edition
页:
[1]