渲染部分
上一节中分析了模拟中粒子受力的部分,从宏观上来看,虚幻提供了一个通用的计算受力的算法框架,不仅仅对于3DLiquidDamBreak这一案例的代码而言,其他3D水模拟实现的算法也是几乎一致,只不过打开或关闭了某些开关,使得算法进入了不同的分支去处理不同的问题。
1. 整体思路
</br>
第一步:根据粒子的位置计算得到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可视化的话,结果因该是接近这样的
</br>
而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)
</br>
3.1 模糊
如果不经过模糊,直接从Grid读取并赋值给RT,效果是这样的
</br>
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)
</br>
Triangle方法:在上一个方法的基础上增加了权重的计算,越靠近当前位置的采样点权重越大
float KernelValue = 1. - smoothstep(0, MaxDist, length(SampleVec)); // 0到半径的范围内,权重线性从1到0变化
BlurredValue += Sample * KernelValue; // 增加了权重
TotalKernel+=KernelValue; // 权重累加
模糊后(Triangle)
</br>
Gaussian方法勾选后未生效,故略过
4. 渲染
拿到这张三维空间下的RT后,也就是拿到了SDF信息,现在需要在材质蓝图中,根据SDF计算出当前水体的法线、Mask、深度、Whitewater,再传给SingleLayerWater材质。
材质本身,是在对一个Cube着色
</br>
最关键的部分,就是这个自定义节点的输出信息
</br>
下面将逐一分析传出的值
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的深度信息)
</br>
4.3 Depth
目前为止深度使用的就是立方体的深度,而不是水面的实际深度,而要获得正确的深度,需要在当前深度的上加一个偏移
深度Depth就是步进到水面时记录的那个值
Depth = t*dot(OriginalRayDir, CameraDirectionVector); // 停止位置的深度
这个值减去Cube上的深度,就是需要偏移的值(Depth-PixelDepth)
偏移过后的深度是正确的深度
</br>
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的差值,求出法线方向
}
加上法线以后的效果
</br>
4.5 World Position Offset
这一步也至关重要,由于当前渲染的本质上还是一个Cube,如果相机距离太近,会按照Cube去剔除已经移动到相机背后的部分。
</br>
因此,不仅需要给到正确的Depth Offset,还需要处理下顶点位置的偏移。
由于在上一步已经把深度挪到了正确的位置,所以这里并不需要准确的把顶点移动到水面的位置(逻辑复杂且计算量大),而是分情况进行一个简单处理:
- 第一种情况:流体的BBox8个顶点,至少有一个在相机裁剪空间的可见范围内,且至少有一个点在近裁剪平面后。这种情况就是相机的位置进入到了BBox内部,即上图所示。这时的处理方法是按照相机裁剪空间下的可视范围,和BBox共同构建(一堆顶点的Min Max操作)一个新的BBox。然后将每个输入的顶点,都取整到原BBox的8个顶点上(Round一下),然后直接给到新BBox的8个顶点上。最终输入世界空间顶点位置,和原来的位置相减得到偏移。这样所有的顶点都跑到可视范围里了,就不存在被剔除的情况了。
- 第二种情况:上一种情况外的其他情况。即相机没有进入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; // 世界空间顶点位置
至此,渲染部分代码解析结束
</br>