【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的光照部分,先看看成果:

画饼

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

相关推荐
小江村儿的文杰11 小时前
XCode Build时遇到 .entitlements could not be opened 的问题
ide·macos·ue4·xcode
心怀梦想的咸鱼19 小时前
UE5 第一人称射击项目学习(二)
学习·ue5
暮志未晚Webgl19 小时前
109. UE5 GAS RPG 实现检查点的存档功能
android·java·ue5
心怀梦想的咸鱼19 小时前
UE5 第一人称射击项目学习(完结)
学习·ue5
小春熙子1 天前
Unity图形学之着色器之间传递参数
unity·游戏引擎·技术美术·着色器
优雅永不过时·1 天前
three.js实现地球 外部扫描的着色器
前端·javascript·webgl·three.js·着色器
云璃丶夢紡2 天前
用源码编译虚幻引擎,并打包到安卓平台
android·游戏引擎·虚幻
暮志未晚Webgl2 天前
110. UE5 GAS RPG 实现玩家角色数据存档
java·前端·ue5
踏实探索2 天前
OpenLayers教程12_WebGL自定义着色器:实现高级渲染效果
前端·arcgis·vue·webgl·着色器
Zhichao_973 天前
【UE5】Slider控件样式
ue5