在上一篇文章中,我们已经总结了实现思路。
接下来,我们将在UE5中,从头开始一步一步地构建整个流程。通过这种方法,我们可以利用一个熟悉的开发环境,使那些对着色器不太熟悉的朋友们更好地理解着色器的工作原理。
总体来说,整个流程分为两个部分:绘制部分和渲染部分。为了能够看到实际效果,我们需要先完成渲染部分。因此,在实现绘制功能之前,我们将使用一个预先准备好的体积纹理作为占位。
为了在制作过程中有良好的正反馈,我倾向于先实现着色器,因此使用这张贴图作为临时占位。
这是一张体积纹理的二维展开图。最终,我们会实现实时绘制功能,用绘制的纹理来替换这张静态的贴图。
创建材质
首先创建材质,并将混合模式调整为 半透明
,着色模式调整为 无光照
制作基础体积着色器
一、光线步进
采样贴图并通过步进对密度进行累计
首先,我们先创建Custom节点,并添加输入
Tip:
可以复制下方代码,然后在UE中,通过输入
右键鼠标,进行粘贴((InputName="Tex"),(InputName="XYFrames"),(InputName="NumFrames"),(InputName="MaxSteps"),(InputName="StepSize"),(InputName="LocalCamVec"),(InputName="CurPos"))
输入 | 说明 |
---|---|
Tex | 将要进行采样的2D切片纹理对象 |
XYFrames | float2 切片的XY数量 |
NumFrames | 切片总数 |
MaxSteps | 最大步进数 |
StepSize | 步大小 |
LocalCamVec | 本地空间相机方向 |
CurPos | 采样位置 |
接下来,我们将编写一个用于光线步进(Ray Marching)的着色器,在这里可以说是"Camera Ray Marching"。其基本原理是,让屏幕上的每个像素沿着相机的朝向发射一条射线。这条射线从起点到终点的过程被称为"步进(Step)"。
在步进过程中,我们会在每一步采样纹理。如果采样到的纹理中存在有效值,则将其累加,这个累加值称为每一步的密度。经过预定的最大步数后,通过查看这条射线累积的总密度值,我们就可以确定该像素的光线穿透程度。
在代码中粘贴如下代码,对每一步的功能进行了注释
//创建变量,从0开始累加沿相机方向步进过程中的总密度
float accumdens = 0;
//使用 MaxSteps 作为最大步数进行循环,每次循环执行以下操作
for (int i = 0; i < MaxSteps; i++)
{
// 在当前步进位置进行纹理采样,采样的是 R 通道
// PseudoVolumeTexture 函数用于伪体积纹理采样,函数需要的参数在括号内传递
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, NumFrames).r;
// 将当前采样到的密度值累加到总密度中
// 乘以步长是为了将采样密度与步进距离相匹配
accumdens += cursample * StepSize;
// 为下次循环更新射线位置,沿着相机方向步进
CurPos += -LocalCamVec * StepSize;
}
//返回累计结果
return accumdens;
创建对应变量输入,并将其连接到输出,就可以查看结果了
有几个新手要注意的点:
- Tex 输入的是“纹理对象”而不是“纹理采样”
- 使用TransfromVector函数将CameraVector由场景转换为本地
- 如果你是较早的UE5版本,Custom的代码要填在"这里"
- 代码中的PseudoVolumeTexture是内建的采样函数,运作原理在第一篇
- 函数
BoundingBoxBased_0-1_UVW
就像他的名字一样,输出bound体积中的每个位置,你可以将RGB
连接到输出查看
他会基于Bound提供位置
二、修复伪影
当从侧方看去,会发现有伪影
正面 | 侧面 |
---|---|
![]() | ![]() |
这里发生的事:
我们可以力大砖飞!
我们可以力大砖飞 的通过提高MaxSteps解决,但我们有更好的办法:
为了按照上图中的方式进行采样,我们将要使用这个函数:
他属于Volumetrics插件,如果你开启了这个插件就可以直接使用
注意:
- 如果并不想开启这个插件,接下来将制作这个函数
- 如果你启用了这个插件,也需要将此函数复制一个,因为后续需要对其修改
为VolumeBoxIntersect函数建立输入和输出
输入① | 输入说明 | 输出② | 输出说明 |
---|---|---|---|
Plane alignment | 对齐起始位置与平面间隔以减少采样伪影 | Box Distance | - |
Steps | 应该使用多少步进来进行平面对齐。应与步进匹配 | Intersection Position | - |
创建Custom函数
重命名为 Ray March Cube Setup
要注意:
- 输出类型为float4
- 在这里将Custom节点重命名
输入粘贴为:
((InputName="PlaneAlignment",Input=(Expression="/Script/Engine.MaterialExpressionFunctionInput'/Engine/Transient.MaterialFunction_0:MaterialExpressionFunctionInput_1'")),(InputName="MaxSteps",Input=(Expression="/Script/Engine.MaterialExpressionFunctionInput'/Engine/Transient.MaterialFunction_0:MaterialExpressionFunctionInput_0'")),(InputName="scenedepth",Input=(Expression="/Script/Engine.MaterialExpressionSceneDepth'/Engine/Transient.MaterialFunction_0:MaterialExpressionSceneDepth_1'",Mask=1,MaskR=1)))
代码为:
float localscenedepth = scenedepth;
float3 camerafwd = mul(float3(0.00000000,0.00000000,1.00000000),(float3x3)ResolvedView.ViewToTranslatedWorld);
float3 depthpos = ((Parameters.CameraVector * localscenedepth) / abs( dot( camerafwd, Parameters.CameraVector ) ) );
depthpos = mul(depthpos, (float3x3)WSDemote(GetWorldToLocal(Parameters))).xyz;
depthpos /= 256;
localscenedepth = length(depthpos);
//0-1
//localscenedepth /= (GetPrimitiveData(Parameters).LocalObjectBoundsMax.x * 2 * scale);
//localscenedepth /= abs( dot( camerafwd, Parameters.CameraVector ) );
//bring vectors into local space to support object transforms
float3 localcampos = mul(float4( DFDemote(ResolvedView.WorldCameraOrigin),1.00000000), (WSDemote(GetWorldToLocal(Parameters)))).xyz;
float3 localcamvec = -normalize( mul(Parameters.CameraVector, (float3x3)WSDemote(GetWorldToLocal(Parameters))) );
//make camera position 0-1
localcampos = (localcampos / (GetPrimitiveData(Parameters).LocalObjectBoundsMax.x * 2)) + 0.5;
float3 invraydir = 1 / localcamvec;
float3 firstintersections = (0 - localcampos) * invraydir;
float3 secondintersections = (1 - localcampos) * invraydir;
float3 closest = min(firstintersections, secondintersections);
float3 furthest = max(firstintersections, secondintersections);
float t0 = max(closest.x, max(closest.y, closest.z));
float t1 = min(furthest.x, min(furthest.y, furthest.z));
float planeoffset = 1-frac( ( t0 - length(localcampos-0.5) ) * MaxSteps );
t0 += (planeoffset / MaxSteps) * PlaneAlignment;
t1 = min(t1, localscenedepth);
t0 = max(0, t0);
float boxthickness = max(0, t1 - t0);
float3 entrypos = localcampos + (max(0,t0) * localcamvec);
return float4( entrypos, boxthickness );
如果您的项目中遇到
DFDemote
或WSDemote
报错,这可能是因为您使用的不是 UE5.4 或更高版本。
它们主要用于处理浮点数的精度问题。
在这种情况下,您可以使用LWCToFloat
进行替换。
计算过程上一篇有介绍,这里不赘述,效果如下
现在我们已经完成了对伪影的修复。可以看到,原本四周存在伪影的部分现在呈现出一种“扭曲”效果,这是因为当前的 MaxSteps
设定为 16。
为了获得更好的效果,可以将 MaxSteps
适当提升到 32 或 64,这样仍能保持不错的性能。
如果不进行伪影修复,而是通过增加步数的方法(力大砖飞)来提升细节质量,通常需要将
MaxSteps
调至 256 甚至更高,才能达到相同的细节水平。
而循环里目前仅仅只有三个步骤,后续扩展效果几乎不可能,因此这一步伪影处理是必须的
Tips
再描述一下原理,最早是直接使用Cube的UVW时,相当于:
这样在有透视的情况下,Step是不均匀的
而 VolumeBoxIntersect修复的方法是:
这样从相机方向step就均匀了
找了个图,能更好的表达意思↓
创建材质函数VolumeBoxIntersect
三、相机进入内部
现在,相机不可以进入box内部。因为着色器只在模型表面进行渲染
我们可以力大砖飞!
我们可以力大砖飞 的通过开启材质双面解决,但我们依旧有更好的办法:
创建法线向内的Box模型
使用建模模式,快速的创建一个法线向内的Box
(如果你有轴在中心的法线向内的box模型则可以跳过)
进入建模模式
如果你没找到建模模式,你需要开启引擎的插件:
创建Box
- 选择建模模式
- 选择创建功能
- 选择创建Box
- 枢纽点选择居中
- 在场景中单击,放置模型
- 点击接受创建模型
调整法线
- 保持选中模型
- 选择属性修改
- 选择法线修改
- 勾选翻转法线
- 接受修改
现在将材质放入
可以看到相机进入"内部"也不会超出模型承载的渲染范围
四、介质光吸收
在将输出连接到不透明度并添加颜色后,我们可以更直观地观察到密度效果。然而,此时的效果可能显得较为“扁平”。为了改善这一点,我们需要运用布格-朗伯-比尔定律(Beer-Lambert Law),以更精确地计算介质对光的吸收。
BeersLaw是非常便宜的粒子体积的透射率计算公式,为我们提供了一种有效的方法来模拟光在介质中传播时的衰减。
在上一篇文章中,我们已经介绍过这个公式,因此这里不再赘述,而是直接使用UE5中自带的函数BeersLaw
。
现在看上去好多了。
虽然还没有阴影和光照,但在这之前,我们先去处理另外的问题
五、体积深度
我们目前还缺乏体积的深度,体积只是被渲染到了模型的内表面 ,通过这个对比可以看到发生了什么:
shader | mesh |
---|---|
![]() | ![]() |
1.禁用深度测试
现在我们为其重新制作深度
为此进入材质,禁用深度测试,接下来深度由我们说的算
Tips
透明度可以被视为一种后期材质处理(尽管相对于“后期处理”,它稍微靠前)。它是在场景渲染完成后再进行处理的,这意味着透明度本质上是没有深度信息的,它呈现于屏幕的最表层。
在选项中提到的“深度测试”实际上是通过利用场景深度来计算透明度的遮罩。这个遮罩会遮挡被场景中的物体阻挡的部分,然后把被挡住的部分修改为透明,使得透明度模型看起来像是在场景中自然存在。
而“禁用深度测试”则是指关闭这个自动计算的深度遮挡功能。
这样一来,透明效果就不再受到场景深度的影响,所有透明材质将直接渲染在最前面。然后由我们手动来计算这个“蒙版”。
2.制作深度
计算 MaxSteps
BoxDistance是框距离,相当于是射线穿透框的厚度,将其乘MaxSteps对其缩放,使用Floor取整,且使用clamp确保它不会超过限制
修改 VolumeBoxIntersect
可以看到当前的深度对的不多
现在要修复这个问题:
重新进入到 VolumeBoxIntersect 函数的 Custom
这里有几行被注释掉的内容,大体上是用于将局部场景深度转换到 0-1 范围内
为Custom增加输入 scale
scale = length(TransformLocalVectorToWorld(Parameters,float3( 1.0 , 0.0 , 0.0 )).xyz);
其蓝图也就是:
Tip:
Q:为什么不直接写进cutom?
A:尽最大可能的把计算过程写为蓝图,而不要在custom内计算
看编译后的HLSL就会知道,UE的材质编译器会对蓝图的内容进行接近疯狂的优化(而custom不会,写什么就是什么)
(后续会用蓝图重制这个custom的大部分内容,但目前先这样,当下还有很多工作没完成)
然后我们注释掉红框,并解除绿框的注释:
修改后的函数如下:
修改后的 Ray March Cube Setup
代码为:
float localscenedepth = scenedepth;
float3 camerafwd = mul(float3(0.00000000,0.00000000,1.00000000),(float3x3)ResolvedView.ViewToTranslatedWorld);
/*
float3 depthpos = ((Parameters.CameraVector * localscenedepth) / abs( dot( camerafwd, Parameters.CameraVector ) ) );
depthpos = mul(depthpos, (float3x3)WSDemote(GetWorldToLocal(Parameters))).xyz;
depthpos /= 256;
localscenedepth = length(depthpos);
*/
//0-1
localscenedepth /= (GetPrimitiveData(Parameters).LocalObjectBoundsMax.x * 2 * scale);
localscenedepth /= abs( dot( camerafwd, Parameters.CameraVector ) );
//bring vectors into local space to support object transforms
float3 localcampos = mul(float4( DFDemote(ResolvedView.WorldCameraOrigin),1.00000000), (WSDemote(GetWorldToLocal(Parameters)))).xyz;
float3 localcamvec = -normalize( mul(Parameters.CameraVector, (float3x3)WSDemote(GetWorldToLocal(Parameters))) );
//make camera position 0-1
localcampos = (localcampos / (GetPrimitiveData(Parameters).LocalObjectBoundsMax.x * 2)) + 0.5;
float3 invraydir = 1 / localcamvec;
float3 firstintersections = (0 - localcampos) * invraydir;
float3 secondintersections = (1 - localcampos) * invraydir;
float3 closest = min(firstintersections, secondintersections);
float3 furthest = max(firstintersections, secondintersections);
float t0 = max(closest.x, max(closest.y, closest.z));
float t1 = min(furthest.x, min(furthest.y, furthest.z));
float planeoffset = 1-frac( ( t0 - length(localcampos-0.5) ) * MaxSteps );
t0 += (planeoffset / MaxSteps) * PlaneAlignment;
t1 = min(t1, localscenedepth);
t0 = max(0, t0);
float boxthickness = max(0, t1 - t0);
float3 entrypos = localcampos + (max(0,t0) * localcamvec);
return float4( entrypos, boxthickness );
深度已经正常,一切都好起来了…?
六、缩放修复
当你试图缩放cube,你会发现它破碎了
眼看着美好的事物在你的操作下支离破碎,这可太令人崩溃了,究其原因在于
TransformVector
节点做了多余的非规格化计算,那么我们使用Custom手搓一个替代它吧:
- 创建Custom,命名为
TransFromVector_WorldToLocal
- 添加输入
CameraVector
- 代码如下
return normalize(mul(CameraVector,(float3x3)LWCToFloat(GetPrimitiveData(Parameters).WorldToLocal)));
七、阶梯纹理修复
没有时间庆祝,接下来赶到战场的是阶梯问题。深度和缩放一旦被修复,图中画圈位置的阶梯状图形就会非常显眼。
这很明显这是由step产生的,因为我们的步(step)是预计算的,因此步与步之间就形成了这种阶梯纹理
我们可以力大砖飞!
我们可以力大砖飞 的通过增加MaxSteps数量,用更小的细分来解决,但总是有更好的办法:
实际上我们只需要在进行额外一次采样就可以了
- 使用frac取小数作为一小步
FinalStepSize
输入 - 在Custom中的for后再进行一次采样
修改后代码如下:
//创建变量,从0开始累加沿相机方向步进过程中的总密度
float accumdens = 0;
//使用 MaxSteps 作为最大步数进行循环,每次循环执行以下操作
for (int i = 0; i < MaxSteps; i++)
{
// 在当前步进位置进行纹理采样,采样的是 R 通道
// PseudoVolumeTexture 函数用于伪体积纹理采样,函数需要的参数在括号内传递
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, NumFrames).r;
// 将当前采样到的密度值累加到总密度中
// 乘以步长是为了将采样密度与步进距离相匹配
accumdens += cursample * StepSize;
// 为下次循环更新射线位置,沿着相机方向步进
CurPos += -LocalCamVec * StepSize;
}
//修复阶梯 额外在循环后再进行一次小步(FinalStepSize)的采样,累计到密度
CurPos -= -LocalCamVec * StepSize;
CurPos += -LocalCamVec * StepSize * FinalStepSize;
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, NumFrames).r;
accumdens += cursample * StepSize * FinalStepSize;
//返回累计结果
return accumdens;
到此一个最基本的体积纹理渲染就做好了,也就是所谓"无光照“模型
后续为其实现光照效果