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

在前两篇文章中,我们分别拆解描述了实现原理,并进行了基础的着色器制作。在这一篇文章中,我们将为它实现光照效果


简单的概述

当光线射入体积时,随着光线射入距离的增加,体积中的介质会对光线产生反射和吸收作用,使其逐步损失能量。当路径上的能量降为零时,光线将无法再进入相机。如果光线仍有剩余能量,则这部分能量会进入相机。

这就是透射率的概念。

同时,为了降低复杂性,我们在这里不考虑光在介质内反弹并最终恰巧弹回相机的可能性。考虑这些情况会使问题变得过于复杂,会变得不幸,我们有比尔-朗伯定律。

核心原理就是在上一章中,用步长(step)为射线累计体积密度的同时,额外进行一次计算,计算光线在这一步时所剩余的能量。作为优化,我们只在边界内(紫色框)进行计算,并且当透射率低于某个阈值时,我们就不再继续计算(红色圆圈),如下图所示。

而且你会注意到这个光源是平行光,也就是太阳。如果你想要使用点光源,在计算光线路径时,你需要让黄色光线指向点,并且别忘记考虑点光源的自然衰减等特性。本章节我们将使用最简单的平行光。

完善Shader

在开始之前

在本节,着色器会变得逐渐复杂。因此在继续之前,我们有必要先对当前的工作做一些整理。

1.制作路由

在意大利面的复杂性继续增长之前,为了不必要的混乱,需要把一些面条制作成 路由 ,顾名思义,就是个"无线"的面条

我们先为StepSizeLocalCamVec制作路由

使用起来像这样

清爽多了

2.为Custom命名

后面会出现多个Custom,为避免混淆,需要给它们起名啦(之前忘了:| )

起名为RayMarching

修改 RayMarching 实现光影

1.光线步进计算光影

我们回到 RayMarching 。

之前我们的密度用了一个通道,而光照的颜色需要三个通道,总计四个。因此将输出类型改为四通道。

接下来我们为其增加5个输入,分别是

输入 说明
LightVector 平行光射入方向
ShadowSteps 阴影的步数
ShadowStepSize 步大小
ShadowDensity 对阴影密度的额外控制
ShadowThreshold 阈值,优化掉小于阈值的计算
Density 需要将计算介质吸收的BeersLaw函数(布格-朗伯-比尔)移入内部

老样子,这些可以直接右键粘贴到输入:

cpp 复制代码
((InputName="Tex"),(InputName="XYFrames"),(InputName="NumFrames"),(InputName="MaxSteps"),(InputName="StepSize"),(InputName="LocalCamVec"),(InputName="CurPos"),(InputName="FinalStepSize"),(InputName="LightVector",Input=(Mask=1,MaskR=1,MaskG=1,MaskB=1)),(InputName="ShadowSteps"),(InputName="ShadowStepSize"),(InputName="ShadowDensity"),(InputName="ShadowThreshold"),(InputName="Density"))

可以看到它和相机方向的光线步进很像(其实它才是真正的光线步进不是吗)

现在样子如下:

现在修改RayMarching的Code,在for的内外加入了shadow部分,且使用新的结果作为返回:

上图中的代码如下:

cpp 复制代码
// Code...代码呢?急急国王先别急。
// 下一步还有一个小修改,然后给这阶段完整的代码。
// 不然就成了纯凑字数

我们做出了很多修改,具体修改内容都标注在了注释上。

总的来说,我们把阴影的计算合并了进去,在每次采样密度时进行了一次光线的采样

注意:

  1. LocalCamVecStepSize 是刚才说的"路由",别漏看现在有个乘法。
  2. RayMarching 的输出不要忘记改为4通道。
  3. 介质吸收的函数已经在 Custom 内实现(因为要写进 for)。


现在阴影已经可以正确渲染了。目前,光照方向是手动输入的,稍后我们会使用场景中的阳光方向。但在此之前,我们先实现光源颜色。

2.光源颜色

为RayMarching增加一个输入

输入 说明
LightColor 光源颜色

计算阴影的同时已经计算了光能,因此我们可以在末尾直接乘以颜色:

cpp 复制代码
lightenergy += exp(-shadowdist * ShadowDensity) * cursample * transmittance * LightColor;

修改后的代码:

c 复制代码
//创建变量,从0开始累加沿相机方向步进过程中的总密度
float accumdens = 0;

//Shadow部分
//创建变量,透射率和光线的能量
float transmittance =1;
float3 lightenergy = 0;
//基本和相机方向步进一样,但这些都是常量,不需要写进for里
Density *= StepSize;
LightVector *= ShadowStepSize;
ShadowDensity *= ShadowStepSize;
//一个对数来计算阈值,用来判断光线是否还值得计算
float shadowthresh = -log(ShadowThreshold)/ShadowDensity;

//使用 MaxSteps 作为最大步数进行循环,每次循环执行以下操作
for (int i = 0; i < MaxSteps; i++)
{
	// 在当前步进位置进行纹理采样,采样的是 R 通道
    // PseudoVolumeTexture 函数用于伪体积纹理采样,函数需要的参数在括号内传递
    float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, NumFrames).r;

    //Shadow部分
    if(cursample > 0.001)//如果采样位置没有密度,则跳过
    {
        float3 Lpos = CurPos;//Lpos将作为光线步进的起始位置
        float shadowdist = 0;//和之前的accumdens一样,积累阴影
        for(int s = 0; s < ShadowSteps; s++)
        {
            Lpos += LightVector;//移动步进位置
            float Lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(Lpos), XYFrames, NumFrames).r;//采样
            
            //判断是否在框内,不是则直接break退出for
            float3 shadowboxtest = floor( 0.5+ (abs(0.5-Lpos)));
            //float exitshadowbox = shadowboxtest.x + shadowboxtest.y + shadowboxtest.z;
            float exitshadowbox = dot(shadowboxtest,1);//简短的通道相加
            if(shadowdist > shadowthresh || exitshadowbox >= 1) break;

            shadowdist += Lsample;//累计
        }

        //更新样本和光能,算法是BeersLaw函数
        cursample = 1 -exp(-cursample * Density);
        lightenergy += exp(-shadowdist * ShadowDensity) * cursample * transmittance * LightColor;
        transmittance *= 1-cursample;     
    
    }


    // 将当前采样到的密度值累加到总密度中
    // 乘以步长是为了将采样密度与步进距离相匹配
    //accumdens += cursample * StepSize;
    // 为下次循环更新射线位置,沿着相机方向步进
    //CurPos += -LocalCamVec * StepSize;
    //将StepSize放custom外面了
    CurPos += -LocalCamVec;
}

//修复阶梯,在循环后再进行一次额外采样
/* 目前先注释掉这些,这样我们不必每次修改后都改一次这里。等全部完成后,再重新编写这些内容。
CurPos -= -LocalCamVec * StepSize;
CurPos += -LocalCamVec * StepSize * FinalStepSize;
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, NumFrames).r;
accumdens += cursample * StepSize * FinalStepSize;
*/

//返回累计结果
//return accumdens;
//现在返回
return float4(lightenergy, transmittance);

现在我们实现了"光"和"影"

3.将光照方向和颜色与场景匹配

之前的光照方向和颜色都是手动输入的,如果你有特殊的效果实现,这样做刚好。但如果你希望它能够与场景完美融合,能够使用场景信息进行自动化当然是理想选择。

1.有天空大气时

如果你的场景中包含"SkyAtmosphere",那么可以使用以下方法:

可以通过 SkyAtmosphereLightDirectionSkyAtmosphereLightIlluminance 分别获取"SkyAtmosphere"的光照方向和颜色。

请注意,SkyAtmosphereLightDirection 需要转换为本地空间。

2.未使用天空大气时

如果你未使用"SkyAtmosphere",或者想摆脱对其的依赖,但仍需要Shader与场景融合,则可以使用自定义Custom来直接获取平行光的参数。

新建一个材质函数,命名为 DirectionalLight,并定义两个输出,分别是方向和颜色。为它们分别创建Custom节点,代码如下:

方向:

cpp 复制代码
ResolvedView.DirectionalLightDirection

颜色:

cpp 复制代码
ResolvedView.DirectionalLightColor

函数如下

注意:

1.代码可能会随版本变动,为确保未来的兼容性,最好是使用UE自带的

2.代码没有额外参数(如这里使用的)时,光源索引是0

3.同样需要转为本地空间

现在Shader可以自动匹配环境光照了

3.阴影颜色

现在我们将制作阴影颜色。在此之前,ShadowDensity 是由一个浮点数驱动的,它代表了介质对光的吸收。

现在我们将 ShadowDensity 从浮点值改为三通道的 RGB 颜色,这意味着我们可以针对吸收的波长进行更精细的控制。

介质对波长的吸收是什么关系?如何通过控制ShadowDensity 调整颜色?

介质对波长的吸收与波长的物理性质有关。一般来说,短波长(蓝色)光会比长波长(红色)光更容易被吸收。这种吸收可以通过调整 ShadowDensity 来控制。

ShadowDensity 现在是一个三通道的 RGB 颜色值,用来表示介质对不同波长的光的吸收程度。每个通道的数值越大,表示对该波长的光吸收越多。

例如,ShadowDensity 值为 8, 16, 32,这意味着:

  • 对红色光的吸收是 8
  • 对绿色光的吸收是 16
  • 对蓝色光的吸收是 32

可以想象,当光线穿过介质时,蓝色光会被大量吸收,绿色光中等程度吸收,而红色光吸收最少。因此,更多的红色光最终会穿透介质进入相机,从而呈现出红色。

通过调整 ShadowDensity 的 RGB 值,你可以控制介质对不同波长光的吸收程度,从而改变最终的颜色表现。


4.环境光照颜色

到目前为止,我们只处理了单个光源的散射效果。这种方法通常效果不佳,因为如果光源完全被遮挡,或者根本没有主光源,体积阴影区域就会显得很平淡。为了改善这一点,我们需要引入环境光。

但是,环境光照并不是简单地加一个代表环境光线的颜色就能搞定的。实际上,我们需要从垂直方向对介质采样三个额外的偏移样本。这样做可以帮助我们估计出环境光遮蔽的效果,从而让阴影区域显得更加柔和自然。

为RayMarching增加输入

输入 说明
AmbientDensity 环境光阴影密度
SkyColor 光源颜色

在RayMarching里增加三次采样:

更新后全部代码如下:

cpp 复制代码
//创建变量,从0开始累加沿相机方向步进过程中的总密度
float accumdens = 0;

//Shadow部分
//创建变量,透射率和光线的能量
float transmittance =1;
float3 lightenergy = 0;
//基本和相机方向步进一样,但这些都是常量,不需要写进for里
Density *= StepSize;
LightVector *= ShadowStepSize;
ShadowDensity *= ShadowStepSize;
//一个对数来计算阈值,用来判断光线是否还值得计算
float shadowthresh = -log(ShadowThreshold)/ShadowDensity;

//使用 MaxSteps 作为最大步数进行循环,每次循环执行以下操作
for (int i = 0; i < MaxSteps; i++)
{
	// 在当前步进位置进行纹理采样,采样的是 R 通道
    // PseudoVolumeTexture 函数用于伪体积纹理采样,函数需要的参数在括号内传递
    float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, NumFrames).r;

    //Shadow部分
    if(cursample > 0.001)//如果采样位置没有密度,则跳过
    {
        float3 Lpos = CurPos;//Lpos将作为光线步进的起始位置
        float shadowdist = 0;//和之前的accumdens一样,积累阴影
        for(int s = 0; s < ShadowSteps; s++)
        {
            Lpos += LightVector;//移动步进位置
            float Lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(Lpos), XYFrames, NumFrames).r;//采样
            
            //判断是否在框内,不是则直接break退出for
            float3 shadowboxtest = floor( 0.5+ (abs(0.5-Lpos)));
            //float exitshadowbox = shadowboxtest.x + shadowboxtest.y + shadowboxtest.z;
            float exitshadowbox = dot(shadowboxtest,1);//简短的通道相加
            if(shadowdist > shadowthresh || exitshadowbox >= 1) break;

            shadowdist += Lsample;//累计
        }

        //更新样本和光能,算法是BeersLaw函数
        cursample = 1 -exp(-cursample * Density);
        lightenergy += exp(-shadowdist * ShadowDensity) * cursample * transmittance * LightColor;
        transmittance *= 1-cursample;
        
        //环境光照部分
        shadowdist = 0;//重置一下阴影距离,继续利用它计算光照

        Lpos = CurPos + float3(0,0,0.025);//新位置
        float Lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(Lpos), XYFrames, NumFrames).r;//采样
        shadowdist += Lsample;

        Lpos = CurPos + float3(0,0,0.05);
        Lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(Lpos), XYFrames, NumFrames).r;//采样
        shadowdist += Lsample;

        Lpos = CurPos + float3(0,0,0.15);
        Lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(Lpos), XYFrames, NumFrames).r;//采样
        shadowdist += Lsample;

        lightenergy += exp(-shadowdist * AmbientDensity) *cursample * SkyColor * transmittance;//累计到光

   
    }


    // 将当前采样到的密度值累加到总密度中
    // 乘以步长是为了将采样密度与步进距离相匹配
    //accumdens += cursample * StepSize;
    // 为下次循环更新射线位置,沿着相机方向步进
    //CurPos += -LocalCamVec * StepSize;
    //将StepSize放custom外面了
    CurPos += -LocalCamVec;
}

//修复阶梯,在循环后再进行一次额外采样
/* 目前先注释掉这些,这样我们不必每次修改后都改一次这里。等全部完成后,再重新编写这些内容。
CurPos -= -LocalCamVec * StepSize;
CurPos += -LocalCamVec * StepSize * FinalStepSize;
float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, NumFrames).r;
accumdens += cursample * StepSize * FinalStepSize;
*/

//返回累计结果
//return accumdens;
//现在返回
return float4(lightenergy, transmittance);


现在我们的体积有柔和的环境光啦

将环境光的颜色匹配

同样的,如果你有特殊的效果实现,就继续使用SkyColor作为输入。如果你希望它能够与场景完美融合,就做如下步骤:

1.有天空大气时

使用SkyAtmosphereDistantLightScatteredLuminance取得环境光

2.没有天空大气时
1.SkyLightEnvMapSample

可以使用SkyLightEnvMapSample,沿垂直方向(0,0,-1)采样

2.ResolvedView.SkyLightColor

创建Custom,并使用

cpp 复制代码
ResolvedView.SkyLightColor

获取天光光源颜色

要注意它获取的是"光源颜色"

关于匹配场景颜色的Tip:

预览窗没有"SkyAtmosphere"

因此不依赖"SkyAtmosphere"的方案,可以在材质的预览窗口中预览

当场景有"SkyAtmosphere",则最好使用依赖"SkyAtmosphere"的方案,能更贴合场景实际的效果

本章总结

代码

cpp 复制代码
float accumdens = 0;

//Shadow部分
float transmittance =1;
float3 lightenergy = 0;

Density *= StepSize;
LightVector *= ShadowStepSize;
ShadowDensity *= ShadowStepSize;

float shadowthresh = -log(ShadowThreshold)/ShadowDensity;

//光线步进
for (int i = 0; i < MaxSteps; i++)
{
    float cursample = PseudoVolumeTexture(Tex, TexSampler, saturate(CurPos), XYFrames, NumFrames).r;

    //Shadow部分
    if(cursample > 0.001)
    {
        float3 Lpos = CurPos;
        float shadowdist = 0;
        for(int s = 0; s < ShadowSteps; s++)
        {
            Lpos += LightVector;
            float Lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(Lpos), XYFrames, NumFrames).r;//采样
            
            float3 shadowboxtest = floor( 0.5+ (abs(0.5-Lpos)));
            float exitshadowbox = dot(shadowboxtest,1);//三通道求和
            if(shadowdist > shadowthresh || exitshadowbox >= 1) break;

            shadowdist += Lsample;//累计
        }

        //更新样本和光能,BeersLaw
        cursample = 1 -exp(-cursample * Density);
        lightenergy += exp(-shadowdist * ShadowDensity) * cursample * transmittance * LightColor;
        transmittance *= 1-cursample;
        
        //环境光照
        shadowdist = 0;

        Lpos = CurPos + float3(0,0,0.025);//新位置
        float Lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(Lpos), XYFrames, NumFrames).r;
        shadowdist += Lsample;

        Lpos = CurPos + float3(0,0,0.05);
        Lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(Lpos), XYFrames, NumFrames).r;
        shadowdist += Lsample;

        Lpos = CurPos + float3(0,0,0.15);
        Lsample = PseudoVolumeTexture(Tex, TexSampler, saturate(Lpos), XYFrames, NumFrames).r;
        shadowdist += Lsample;

        lightenergy += exp(-shadowdist * AmbientDensity) *cursample * SkyColor * transmittance;

   
    }

    CurPos += -LocalCamVec;
}
return float4(lightenergy, transmittance);

蓝图

结果

这章我们完成了Shader的光照部分,先看看成果:

画饼

下章我们继续制作"阴影投射"的部分

相关推荐
快下雨了L16 小时前
UE5TSubclassOf模板,定时器的使用,SpawnActor函数的使用,SetStaticMesh函数的使用
ue5
Zhichao_9716 小时前
【UE5 C++课程系列笔记】30——自动拷贝DLL及其他资源
c++·ue5
Bluesonli2 天前
第 16 天:游戏 UI(UMG)开发,打造主菜单 & 血条!
学习·游戏·ui·ue5·虚幻·unreal engine
dontgotobed2 天前
Windows server 2016 无法部署docker问题
服务器·windows·docker·容器·虚幻
Bluesonli4 天前
第 14 天:UE5 C++ 与蓝图(Blueprint)交互!
c++·游戏·ue5·交互·unreal engine
Yang-Never5 天前
OpenGL ES -> 投影变换矩阵完美解决绘制GLSurfaceView绘制图形拉伸问题
android·java·kotlin·android studio·着色器
Bluesonli7 天前
第 9 天:UE5 物理系统 & 碰撞检测全解析!
开发语言·学习·游戏·ue5·虚幻·unreal engine
Bluesonli7 天前
第 10 天:UE5 交互系统,拾取物品 & 触发机关!
学习·游戏·ue5·虚幻·unreal engine
带帯大师兄7 天前
UE5.5 PCGFrameWork--GPU CustomHLSL
ue5·ue5 pcg
Jerico.Gu7 天前
[UE5] 在Custom 节点中自定义函数
ue5