在上一篇文章中,我们已经理顺了实现流程。
接下来,我们将在UE5中,从头开始一步一步地构建一次流程。
通过这种方法,我们可以借助一个熟悉的开发环境,使那些对着色器不太熟悉的朋友们更好地理解着色器的工作原理。
这篇文章中,我们将使用这样一个纹理,点此下载
我喜欢先实现着色器,因此先用这个贴图作为占位
这是一个体积纹理的二维展开,最终我们会用RT纹理来代替这种静态的贴图,在runtime用RT进行实时的绘制
创建材质
首先创建材质,并将混合模式调整为 半透明
,着色模式调整为 无光照
制作基础体积着色器
一、光线步进
采样贴图并通过步进对密度进行累计
首先,我们先创建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。
其基本原理是,让屏幕上的每个像素沿着相机的朝向发射射线。这条射线从起点到终点的过程被称为"步进(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
为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 );
计算过程上一篇有介绍,这里不赘述,效果如下
现在我们已经完成了对伪影的修复。可以看到,原本四周存在伪影的部分现在呈现出一种“扭曲”效果,这是因为当前的 MaxSteps
设定为 16。
为了获得更好的效果,可以将 MaxSteps
适当提升到 32 或 64,这样仍能保持不错的性能。
如果不进行伪影修复,而是通过增加步数的方法(力大砖飞)来提升细节质量,通常需要将
MaxSteps
调至 256 甚至更高,才能达到相同的细节水平。
而循环里目前仅仅只有三个步骤,后续扩展效果几乎不可能,因此这一步伪影处理是必须的
三、相机进入内部
现在,相机不可以进入box内部。因为着色器只在模型表面进行渲染
我们可以力大砖飞!
我们可以力大砖飞 的通过开启材质双面解决,但我们依旧有更好的办法:
创建法线向内的Box模型
使用建模模式,快速的创建一个法线向内的Box
(如果你有轴在中心的法线向内的box模型则可以跳过)
进入建模模式
如果你没找到建模模式,你需要开启引擎的插件:
创建Box
- 选择建模模式
- 选择创建功能
- 选择创建Box
- 枢纽点选择居中
- 在场景中单击,放置模型
- 点击接受创建模型
调整法线
- 保持选中模型
- 选择属性修改
- 选择法线修改
- 勾选翻转法线
- 接受修改
现在将材质放入
可以看到相机进入"内部"也不会超出模型承载的渲染范围
四、介质光吸收
将输出连入不透明度,再加上一个颜色。此时能更好的看到它的密度看上去非常“扁平”。
为此要使用布格-朗伯-比尔定律,来更好的计算介质对光的吸收
它的公式在上一篇中有介绍,这里不赘述,这里我们直接使用自带的函数BeersLaw
它将返回有多少光被材质吸收
现在看上去好多了。
虽然还没有阴影和光照,但在这之前,我们先去处理另外的问题
五、体积深度
我们目前还缺乏体积的深度,体积只是被渲染到了模型的内表面 ,通过这个对比可以看到发生了什么:
shader | mesh |
---|---|
1.禁用深度测试
现在我们为其重新制作深度
为此进入材质,禁用深度测试,接下来深度由我们说的算
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手搓一个使用LWC精度的替代节点吧:
- 创建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;
到此一个最基本的体积纹理渲染就做好了
待续
就上三天班又放假?
等回来就开始在下一篇进行【制作进阶体积着色器】,继续为其实现光照等效果
放假回来我们继续,下班