【UE5】将2D切片图渲染为体积纹理,最终实现使用RT实时绘制体积纹理【第二篇-着色器制作】

在上一篇文章中,我们已经理顺了实现流程。

接下来,我们将在UE5中,从头开始一步一步地构建一次流程。

通过这种方法,我们可以借助一个熟悉的开发环境,使那些对着色器不太熟悉的朋友们更好地理解着色器的工作原理。

这篇文章中,我们将使用这样一个纹理,点此下载

我喜欢先实现着色器,因此先用这个贴图作为占位

这是一个体积纹理的二维展开,最终我们会用RT纹理来代替这种静态的贴图,在runtime用RT进行实时的绘制

创建材质

首先创建材质,并将混合模式调整为 半透明 ,着色模式调整为 无光照

制作基础体积着色器

一、光线步进

采样贴图并通过步进对密度进行累计


首先,我们先创建Custom节点,并添加输入

Tip:

可以复制下方代码,然后在UE中,通过 输入 右键鼠标,进行粘贴

c 复制代码
((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)"。在每一步中,我们采样纹理,如果纹理中有值,则将其累加,这个累加值称为每一步的密度。达到最大步数后,通过查看这条射线累积了多少密度,就可以确定这个像素的光线穿透程度。

在代码中粘贴如下代码,对每一步的功能进行了注释

cpp 复制代码
//创建变量,从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

要注意:

  1. 输出类型为float4
  2. 在这里将Custom节点重命名

输入粘贴为:

C 复制代码
((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)))

代码为:

cpp 复制代码
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
  1. 选择建模模式
  2. 选择创建功能
  3. 选择创建Box
  4. 枢纽点选择居中
  5. 在场景中单击,放置模型
  6. 点击接受创建模型
调整法线
  1. 保持选中模型
  2. 选择属性修改
  3. 选择法线修改
  4. 勾选翻转法线
  5. 接受修改

现在将材质放入

可以看到相机进入"内部"也不会超出模型承载的渲染范围

四、介质光吸收

将输出连入不透明度,再加上一个颜色。此时能更好的看到它的密度看上去非常"扁平"。

为此要使用布格-朗伯-比尔定律,来更好的计算介质对光的吸收

它的公式在上一篇中有介绍,这里不赘述,这里我们直接使用自带的函数BeersLaw

它将返回有多少光被材质吸收

现在看上去好多了。

虽然还没有阴影和光照,但在这之前,我们先去处理另外的问题

五、体积深度

我们目前还缺乏体积的深度,体积只是被渲染到了模型的内表面 ,通过这个对比可以看到发生了什么:

shader mesh

1.禁用深度测试

现在我们为其重新制作深度

为此进入材质,禁用深度测试,接下来深度由我们说的算

2.制作深度

计算 MaxSteps

BoxDistance是框距离,相当于是射线穿透框的厚度,将其乘MaxSteps对其缩放,使用Floor取整,且使用clamp确保它不会超过限制

修改 VolumeBoxIntersect

可以看到当前的深度对的不多

现在要修复这个问题:

重新进入到 VolumeBoxIntersect 函数的 Custom

这里有几行被注释掉的内容,大体上是用于将局部场景深度转换到 0-1 范围内

为Custom增加输入 scale

cpp 复制代码
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 代码为:

cpp 复制代码
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
  • 代码如下
cpp 复制代码
return normalize(mul(CameraVector,(float3x3)LWCToFloat(GetPrimitiveData(Parameters).WorldToLocal)));

七、阶梯纹理修复

没有时间庆祝,接下来赶到战场的是阶梯问题。深度和缩放一旦被修复,图中画圈位置的阶梯状图形就会非常显眼。

这很明显这是由step产生的,因为我们的步(step)是预计算的,因此步与步之间就形成了这种阶梯纹理

我们可以力大砖飞!

我们可以力大砖飞 的通过增加MaxSteps数量,用更小的细分来解决,但总是有更好的办法

实际上我们只需要在进行额外一次采样就可以了

  • 使用frac取小数作为一小步FinalStepSize输入
  • 在Custom中的for后再进行一次采样

修改后代码如下:

cpp 复制代码
//创建变量,从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;


到此一个最基本的体积纹理渲染就做好了


在下一篇进行【制作进阶体积着色器】,继续为其实现光照等效果


相关推荐
向宇it3 小时前
【从零开始入门unity游戏开发之——C#篇25】C#面向对象动态多态——virtual、override 和 base 关键字、抽象类和抽象方法
java·开发语言·unity·c#·游戏引擎
向宇it4 小时前
【从零开始入门unity游戏开发之——C#篇24】C#面向对象继承——万物之父(object)、装箱和拆箱、sealed 密封类
java·开发语言·unity·c#·游戏引擎
每日出拳老爷子7 小时前
【图形渲染】【Unity Shader】【Nvidia CG】有用的参考资料链接
unity·游戏引擎·图形渲染
YY-nb16 小时前
Unity Apple Vision Pro 开发教程:物体识别跟踪
unity·游戏引擎·apple vision pro
向宇it1 天前
【从零开始入门unity游戏开发之——C#篇23】C#面向对象继承——`as`类型转化和`is`类型检查、向上转型和向下转型、里氏替换原则(LSP)
java·开发语言·unity·c#·游戏引擎·里氏替换原则
Cool-浩1 天前
Unity 开发Apple Vision Pro空间锚点应用Spatial Anchor
unity·游戏引擎·apple vision pro·空间锚点·spatial anchor·visionpro开发
一个程序员(●—●)2 天前
四元数旋转+四元数和向量相乘+音频相关
unity·游戏引擎
我的巨剑能轻松搅动潮汐2 天前
【UE5】pmx导入UE5,套动作。(防止“气球人”现象。
ue5
冒泡P2 天前
【Lua热更新】上篇
开发语言·数据结构·unity·c#·游戏引擎·lua
十画_8242 天前
Unity 6 中的新增功能
unity·游戏引擎