NiagaraFluids代码分析2:渲染部分

发布于:2022-12-10 ⋅ 阅读:(967) ⋅ 点赞:(0)

渲染部分

上一节中分析了模拟中粒子受力的部分,从宏观上来看,虚幻提供了一个通用的计算受力的算法框架,不仅仅对于3DLiquidDamBreak这一案例的代码而言,其他3D水模拟实现的算法也是几乎一致,只不过打开或关闭了某些开关,使得算法进入了不同的分支去处理不同的问题。

1. 整体思路

第一步:根据粒子的位置计算得到SDF(Signed Distance Field),存在RasterzationGrid中。
第二步:模糊SDF信息并将其存入Render Target中。
第三步:使用Single Layer Water材质渲染得到水。

2. Fill Rasterization Grid

填满Rasterization Grid。这是一个三维空间的Grid,数据类型是Rasterization Grid3D(参考:Niagara RasterizationGrid3D Data Interface
循环每个粒子,根据粒子的半径和Grid中Cell的半径,计算出要在Grid中遍历的格点范围,在每个格点上存入到粒子表面的最小距离。

int IGNORE;

// we never want a radius smaller than half of the cell size otherwise
// we can't rasterize an sdf
float Radius = max(SpriteSize.x * .5 * RadiusMult, dx * .5); // 粒子的半径 或者 Cell的半径 取较大的一个


float IndexRadius = Radius / dx; // 把半径单位换成Index,与下边统一了尺度
int size = ceil(IndexRadius) + HalfBandwidth; // 大于等于2的整数,采样范围

int IndexX = round(Index.x); // 当前粒子在RasterizationGrid下的位置,取整到格点上
int IndexY = round(Index.y);
int IndexZ = round(Index.z);

for (int xx = -size; xx <= size; ++xx) {
for (int yy = -size;  yy <= size; ++yy) {
for (int zz = -size; zz <= size; ++zz) {
    int3 CurrIndex = int3(IndexX+xx,IndexY+yy,IndexZ+zz);

    float IndexDist = length(Index - CurrIndex) - IndexRadius; // 粒子表面距离采样点的距离,以Index为单位(而非世界空间下的距离单位)
    if (abs(IndexDist) <= HalfBandwidth &&
            CurrIndex.x >= 0 && CurrIndex.x < NumCellsX &&
            CurrIndex.y >= 0 && CurrIndex.y < NumCellsY &&
            CurrIndex.z >= 0 && CurrIndex.z < NumCellsZ)
    {
            // 存入格点中,当前采样点到粒子表面 最小 世界空间距离
            Grid.InterlockedMinFloatGridValue(CurrIndex.x, CurrIndex.y, CurrIndex.z, 0, IndexDist * dx, IGNORE); 
            // 建立了一个距离场,最小值是-Radius
    }
}}}

关于SDF参考 Understanding the SDF - Signed Distance Field (part 1/3)
对比视频中的案例,如果将这一阶段输出的Grid可视化的话,结果因该是接近这样的

而UE材质系统是无法将Grid作为材质的输入进行渲染的,但Render Target(一种体积纹理-Volume Texture)可以。所以下一个阶段,需要将存在Grid中的SDF信息,转换到RT中

3. Build SDF

迭代源选择了Render Target:SimRT,这一步将Grid上存储的值(Signed Distance),经过模糊处理后,赋值给了RT。
在这一步中,Grid和RT的尺寸是完全一致的,直接在相同的Index位置上存入RGBA值(Grid上的值存在了R通道,其他通道给0)

3.1 模糊

如果不经过模糊,直接从Grid读取并赋值给RT,效果是这样的

UE现有的模块提供了3种模糊的方式:Gaussian Box Triangle,以下展示效果模糊的范围都是1个格子
Box方法:在一个正方体包围的范围里累加采样点,最后平均

BlurredValue = 0;
float Width = Radius * 2. + 1;

float TotalKernel = 0;

int3 CurrCell = int3(IndexX, IndexY, IndexZ); // 当前Cell的Index
int3 MaxCells = int3(NumCellsX, NumCellsY, NumCellsZ)  - 1;

for (int xx = -Radius; xx <= Radius; ++xx) {
for (int yy = -Radius; yy <= Radius; ++yy) {
for (int zz = -Radius; zz <= Radius; ++zz) {
  int3 SampleVec = int3(xx,yy,zz);

  int3 CurrIndex = clamp(CurrCell + SampleVec, int3(0,0,0), MaxCells); // 被采样的Cell的Index Clamp保证了采样点在0至max-1的范围内
 if (  CurrIndex.x >= 0 && CurrIndex.x < NumCellsX &&
        CurrIndex.y >= 0 && CurrIndex.y < NumCellsY &&
        CurrIndex.z >= 0 && CurrIndex.z < NumCellsZ) 
  {
    float Sample;
    Grid.GetFloatGridValue(CurrIndex.x, CurrIndex.y, CurrIndex.z, 0, Sample); // 采样
    BlurredValue += Sample; // 累加,权重都是1
    TotalKernel++;
  }
}}}

BlurredValue /= TotalKernel; // 平均

模糊后(Box)

Triangle方法:在上一个方法的基础上增加了权重的计算,越靠近当前位置的采样点权重越大

float KernelValue = 1. - smoothstep(0, MaxDist, length(SampleVec)); // 0到半径的范围内,权重线性从1到0变化
BlurredValue += Sample * KernelValue; // 增加了权重
TotalKernel+=KernelValue; // 权重累加

模糊后(Triangle)

Gaussian方法勾选后未生效,故略过

4. 渲染

拿到这张三维空间下的RT后,也就是拿到了SDF信息,现在需要在材质蓝图中,根据SDF计算出当前水体的法线、Mask、深度、Whitewater,再传给SingleLayerWater材质。
材质本身,是在对一个Cube着色

最关键的部分,就是这个自定义节点的输出信息

下面将逐一分析传出的值

4.1 Emissive

Whitewater这个值用于自发光,从Custom节点输出后乘了一个参数传给Emissive。但从自定义节点的代码来看,始终返回的都是0

// Custom节点中与Whitewater相关的代码
Whitewater = 0; 
for (int i = 0; i < NumSteps; ++i)
{
    if (...)
    {
        Whitewater = VolumeSample.g; // g通道为0,目前只有r通道存了sdf
        break;
    }
}

4.2 Opacity Mask

Custom节点返回值的A通道,存储了Opacity Mask,用于剔除不显示的部分。
剔除后:此时还没有法线和深度信息,深度仅仅是Cube本身的深度

这里用到了AABB方法,Aixe align bounding box
详细见参考:3D空间中射线与轴向包围盒AABB的交叉检测算法
概括来说就是检测射线和包围盒的碰撞,获得碰撞点的位置,就是水的表面。在比较场景深度和当前点的深度,如果没有被遮挡就显示(A通道给1),被遮挡了就剔除(A通道给0)
step1:将所有A通道默认为0,射线碰撞到的部分设置为1,同时记录下碰撞点的深度信息,位置信息

float4 RetVal = float4(0,0,0,0); // 初始化返回值
for (int i = 0; i < NumSteps; ++i) // 默认步进300次
{
   
    float3 Position = CurrLocalPos / WorldGridExtents + .5; // 获取在RT中的坐标 0-1
    float4 VolumeSample = VolumeTexture.SampleLevel(VolumeTextureSampler, Position, 0);
    SignedDistance = VolumeSample.r; // 读取SDF
    // 若读取的到SDF已经小于体素了且还在范围中,则跳出循环,不再递进
    if (abs(SignedDistance) < VoxelSize * Tolerance && Position.x >= 0 && Position.x <= 1 && Position.y >= 0 && Position.y <= 1 && Position.z >= 0 && Position.z <= 1)
    {
        FinalPos = Position; // 记录射线停止的位置
        RetVal = float4(normalize(VolumeSample.gba), 1); // 0001 1表示保留了Mask
        Depth = t*dot(OriginalRayDir, CameraDirectionVector); // 停止位置的深度
        break;
    }
    
    t += SignedDistance; // 根据SDF信息向前递进,每次前进SignedDistance的长度
    CurrLocalPos = RayStart + RayDir * t;
}

step2:把碰撞到深度信息,与场景整体的深度信息作比较,如果被遮挡了,就设置为0

float WorldDepth = SceneDepth / dot(RayDir, CameraDirectionVector); // 射线方向的深度值 越远值越大
if (t > WorldDepth)
{
    RetVal.a = 0; // 被遮挡的部分直接剔除
}

加上剔除以后的效果(此时还没有深度信息,用的是Cube的深度信息)

4.3 Depth

目前为止深度使用的就是立方体的深度,而不是水面的实际深度,而要获得正确的深度,需要在当前深度的上加一个偏移
深度Depth就是步进到水面时记录的那个值

Depth = t*dot(OriginalRayDir, CameraDirectionVector); // 停止位置的深度

这个值减去Cube上的深度,就是需要偏移的值(Depth-PixelDepth)
偏移过后的深度是正确的深度

4.4 Normal

法线这部分是使用了当前位置的周围的SDF信息,相减得到的,具体原理暂时不太理解,从计算结果上看是正确的

if (RetVal.a != 0 && ComputeNormals > 1-1e-5) // 若没有被遮挡,且需要计算法线(默认需要),则计算法线
{
    uint sx,sy,sz,l;
    VolumeTexture.GetDimensions(0, sx, sy, sz, l);
    float3 UnitDx = 1./float3(sx,sy,sz);

    float S_right = VolumeTexture.SampleLevel(VolumeTextureSampler, FinalPos + float3(1,0,0) * UnitDx, 0).r;
    float S_left = VolumeTexture.SampleLevel(VolumeTextureSampler, FinalPos - float3(1,0,0) * UnitDx, 0).r;
    float S_up  = VolumeTexture.SampleLevel(VolumeTextureSampler, FinalPos + float3(0,1,0) * UnitDx, 0).r;
    float S_down = VolumeTexture.SampleLevel(VolumeTextureSampler, FinalPos - float3(0,1,0) * UnitDx, 0).r;
    float S_front = VolumeTexture.SampleLevel(VolumeTextureSampler, FinalPos + float3(0,0,1) * UnitDx, 0).r;
    float S_back = VolumeTexture.SampleLevel(VolumeTextureSampler, FinalPos - float3(0,0,1) * UnitDx, 0).r;

    RetVal.rgb = normalize(float3(S_right - S_left, S_up - S_down, S_front - S_back)); // 利用相邻SDF的差值,求出法线方向

}

加上法线以后的效果

4.5 World Position Offset

这一步也至关重要,由于当前渲染的本质上还是一个Cube,如果相机距离太近,会按照Cube去剔除已经移动到相机背后的部分。

因此,不仅需要给到正确的Depth Offset,还需要处理下顶点位置的偏移。
由于在上一步已经把深度挪到了正确的位置,所以这里并不需要准确的把顶点移动到水面的位置(逻辑复杂且计算量大),而是分情况进行一个简单处理:

  1. 第一种情况:流体的BBox8个顶点,至少有一个在相机裁剪空间的可见范围内,且至少有一个点在近裁剪平面后。这种情况就是相机的位置进入到了BBox内部,即上图所示。这时的处理方法是按照相机裁剪空间下的可视范围,和BBox共同构建(一堆顶点的Min Max操作)一个新的BBox。然后将每个输入的顶点,都取整到原BBox的8个顶点上(Round一下),然后直接给到新BBox的8个顶点上。最终输入世界空间顶点位置,和原来的位置相减得到偏移。这样所有的顶点都跑到可视范围里了,就不存在被剔除的情况了。
  2. 第二种情况:上一种情况外的其他情况。即相机没有进入BBox,不存在被剔除的情况,自然也就不需要计算WPO了

下边的代码给出了计算新顶点位置的过程(没放定义矩阵的部分)

// Unit space position of the current vertex
float3 InUnit = mul(float4(InWorld,1), WorldToLocal).xyz; // World To Local
InUnit = InUnit / WorldGridExtents + .5; // 把输入的世界空间位置,转换到局部空间并归一化 0-1 Local To Unit

float3 CameraLocal = mul(float4(CameraWorld,1), WorldToLocal).xyz;
float3 CameraUnit = CameraLocal / WorldGridExtents + .5; // 相机世界空间位置同理

float3 OutWorldVertexPos = InWorld; // 初始化输出的世界空间位置
// true if at least one vertex is behind the near clip plane
bool VertexBehind = false; // 是否有顶点在近裁剪平面后

// true if at least one vertex is visible
bool VertexVisible = false; // 顶点是否可见

// evaluate the 8 corners of the fluid bbox in clip space 
// find the axis aligned bbox in clip space
// 根据原流体模拟边界,确定一个在裁剪空间下的bbox
float4 BBoxClipMin = float4(INFINITE_FLOAT,INFINITE_FLOAT,INFINITE_FLOAT,INFINITE_FLOAT); // bbox在裁剪空间下的起点和终点
float4 BBoxClipMax = -1. * float4(INFINITE_FLOAT,INFINITE_FLOAT,INFINITE_FLOAT,INFINITE_FLOAT);
float MinW = INFINITE_FLOAT;
float MaxW = -1. * INFINITE_FLOAT;
for (int x = 0; x <= 1; ++x) {
for (int y = 0; y <= 1; ++y) {
for (int z = 0; z <= 1; ++z) {
    const float3 BBoxUnit = float3(x,y,z); // 流体模拟空间下8个角的坐标
    const float3 BBoxLocal = (BBoxUnit - .5) * WorldGridExtents; // Unit To Local
    const float3 BBoxWorld =  mul(float4(BBoxLocal,1), LocalToWorld).xyz; // Local To World
    float4 BBoxClip = mul(float4(BBoxWorld,1),LWCToFloat(ResolvedView.WorldToClip)); // 8个角的裁剪空间坐标 World To Clip

    // if this vertex is in front of the near clip plane, add it to the aabbox
    if (BBoxClip.w > 0) // w大于0表示在裁剪空间里
    {
        // vertex is visible
        if (any(BBoxClip.xy <= BBoxClip.w) && any(-BBoxClip.xy <= BBoxClip.w))
        {
            VertexVisible = true; // 只要8个点有一个看得到,就设置为真
        }

        MinW = min(BBoxClip.w, MinW); // 更新W的极值
        MaxW = max(BBoxClip.w, MaxW);

        BBoxClip /= BBoxClip.w;
    
        BBoxClipMin = min(BBoxClip, BBoxClipMin); // 只更新在近裁剪平面前的点作为边界
        BBoxClipMax = max(BBoxClip, BBoxClipMax); 
    } else
    {
        VertexBehind = true; // 8个点有在裁剪面之后的,设置为真
    }
}}}

// if we have a visible vertex and at least one vertex is behind the near clip plane
// then transform to a clip aligned bbox
// 原包围盒中,至少有1个点可见,且至少有1个点在近裁剪平面之后。否则不需要额外设置WPO
if (VertexVisible && VertexBehind)
{
      BBoxClipMin = min(BBoxClipMin, float4(-1,-1,1,1));
      BBoxClipMax = max(BBoxClipMax, float4(-1,-1,1,1));

      BBoxClipMin = min(BBoxClipMin, float4(1,1,1,1));
      BBoxClipMax = max(BBoxClipMax, float4(1,1,1,1)); // Clamp保存的xy极值到-1至1的范围内
         
      // clamp the clip space bbox z to near clip plane
      BBoxClipMax.z = min(1-1e-3, BBoxClipMax.z); // Clamp保存的z极大值到近裁剪面

      float4 BBoxClipSize = BBoxClipMax - BBoxClipMin; // 裁剪空间下BBox的尺度

      // transform the box back to world space
      int4 v = 1-float4(round(InUnit), 1); // 取整输入顶点到8个角
      float4 BBoxClipPos = BBoxClipMin + BBoxClipSize * v; // 取整后在BBox中的位置
      // 上边的转换直接将顶点坐标,从Unit空间下到了新构建的BBox的Clip空间下
      // 就是将Cube上所有的顶点,都按照固定的规律转换到了裁剪空间下的新的BBox中
      // 这样的话所有的顶点,都被round了,然后放在了8个角上,都是可见的

      // output world position for vertex
      float4 Tmp = mul(BBoxClipPos,LWCToFloat(ResolvedView.ClipToWorld));
      OutWorldVertexPos = Tmp.xyz / Tmp.w;// Clip To World
}
return OutWorldVertexPos; // 世界空间顶点位置

至此,渲染部分代码解析结束

本文含有隐藏内容,请 开通VIP 后查看

网站公告

今日签到

点亮在社区的每一天
去签到