在前两篇文章中,我们分别拆解描述了实现原理,并进行了基础的着色器制作。在这一篇文章中,我们将为它实现光照效果
简单的概述
当光线射入体积时,随着光线射入距离的增加,体积中的介质会对光线产生反射和吸收作用,使其逐步损失能量。当路径上的能量降为零时,光线将无法再进入相机。如果光线仍有剩余能量,则这部分能量会进入相机。
这就是透射率的概念。
同时,为了降低复杂性,我们在这里不考虑光在介质内反弹并最终恰巧弹回相机的可能性。考虑这些情况会使问题变得过于复杂,会变得不幸,我们有比尔-朗伯定律。
核心原理就是在上一章中,用步长(step)为射线累计体积密度的同时,额外进行一次计算,计算光线在这一步时所剩余的能量。作为优化,我们只在边界内(紫色框)进行计算,并且当透射率低于某个阈值时,我们就不再继续计算(红色圆圈),如下图所示。
而且你会注意到这个光源是平行光,也就是太阳。如果你想要使用点光源,在计算光线路径时,你需要让黄色光线指向点,并且别忘记考虑点光源的自然衰减等特性。本章节我们将使用最简单的平行光。
完善Shader
在开始之前
在本节,着色器会变得逐渐复杂。因此在继续之前,我们有必要先对当前的工作做一些整理。
1.制作路由
在意大利面的复杂性继续增长之前,为了不必要的混乱,需要把一些面条制作成 路由
,顾名思义,就是个"无线"的面条
我们先为StepSize
和LocalCamVec
制作路由
使用起来像这样
清爽多了
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...代码呢?急急国王先别急。
// 下一步还有一个小修改,然后给这阶段完整的代码。
// 不然就成了纯凑字数
我们做出了很多修改,具体修改内容都标注在了注释上。
总的来说,我们把阴影的计算合并了进去,在每次采样密度时进行了一次光线的采样
注意:
LocalCamVec
和StepSize
是刚才说的"路由",别漏看现在有个乘法。- RayMarching 的输出不要忘记改为4通道。
- 介质吸收的函数已经在 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",那么可以使用以下方法:
可以通过 SkyAtmosphereLightDirection
和 SkyAtmosphereLightIlluminance
分别获取"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的光照部分,先看看成果:
画饼
下章我们继续制作"阴影投射"的部分