本文为个人的学习笔记(复刻),内容以及部分图片都是参考知乎以及各个平台大佬的文章~(参考写在最后)感谢,侵删
使用Unity版本为 Unity 2022.3.8f1c1(URP)
1. RayMarching 光线步进
1.1 原理
与ray tracing 类似,我们也是从摄像机开始向空间中发出一条射线。这个名字就解释了,光线采用一种“步进”的方式来与场景计算求交,光线通过SDF距离场函数在场景中逐渐安全步进,直到击中一个对象为止。
- 从屏幕空间发射一条射线
- 利用SDF距离场函数计算空间中某一点到场景每个物体的最短距离,然后光线下一次就利用这个距离,沿着光线方向进行步进
- 当SDF计算出来的最短距离越来越小,直到足够小的时候,我们就可以认为光线击中了物体;反之,如果最短距离越来越大,直到达到一个足够大的值或者达到最大步进次数,我们就可以认为这个光线没有与物体相交(我们要设定最短距离、最大距离、步进次数)
1.2 RayMarching体积云体积雾的原理
- 如何发射:我们将会采用类似步进的方法。RayMarching会间隔一段距离对一个雾quad进行一次采样,然后叠加每一次的雾浓度
- 发射方向:我们是从摄像机发射一点,对场景进行求交,所以是基于屏幕空间的。如果我们可以找到屏幕上每个像素点的世界坐标位置,通过两个世界坐标相减后normalize,就是我们所需要的射线方向了
- 体积如何产生的:体积是一个范围。我们此时已经可以通过发射方向和步进距离得到每个步进点的世界坐标,所以我们需要划定一个范围。如果步进点在这个范围内,则采样得到采样值,如果不在就返回0。这样累加起来就可以得到一块体积。
- 浓度产生差异:我们需要让上述每个采样值都不一样,所以需要引入3D噪声去处理
2. RayMarching体积云实现
2.1 重建世界坐标系
为了得到每个射线的方向以及计算步进点,我们需要通过屏幕空间来重建世界坐标系。所以是一个后处理算法。需要在urp项目中创建renderer feature来加入后处理功能。
Unity官方文档是通过 Depth Texture 和 屏幕空间UV坐标 来重建每个像素的世界空间坐标。
首先在项目的Universal Render Pipeline Asset中启用深度图
shader要点:
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
/*....*/
half4 frag(Varyings IN) : SV_Target
{
float2 UV = IN.positionCS.xy / _ScaledScreenParams.xy;
#if UNITY_REVERSED_Z
real depth = SampleSceneDepth(UV);
#else
//Adjust Z to match NDC for OpenGL
real depth = lerp(UNITY_NEAR_CLIP_VALUE, 1, SampleSceenDepth(UV));
#endif
float3 worldPos = ComputeWorldSpacePosition(UV, depth, UNITY_MATRIX_I_VP);
/*...*/
}
- 添加声明
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/DeclareDepthTexture.hlsl"
,这里面包含了_CameraDepthTexture的声明 - 然后通过顶点着色器中传进片元着色器的顶点坐标 positionCS,通过
/_ScaledScreenParams.xy
得到UV坐标- _ScaledScreenParams.xy 是渲染目标的Resolution分辨率属性
- positionCS.xy 是裁剪空间坐标,通过除以_ScaledScreenParams.xy 会被映射到 NDC坐标,然后再结合NDC空间的深度值,再与逆投影矩阵和逆视图矩阵左乘就可以还原世界坐标
- 对于深度值获取,声明中还提供了一个函数 SampleSceneDepth(uv),这个函数可以通过屏幕空间uv坐标来采样深度图得到NDC深度值(范围为[0,1])(对于OpenGL平台,NDC深度值为[-1,1],所以要进行平台差异化处理,见代码注释)
- 然后通过得到的 uv坐标以及深度值,使用声明提供的ComputeWorldSpacePosition函数,计算世界坐标
得到世界坐标后,就可以利用世界坐标 - 相机世界坐标,得到一个方向向量,再normalize,就是我们的射线方向
float3 rayDir = (worldPos - GetCameraPositionWS().xyz);
2.2 RayMarching算法实现
我们先假设采样点的值是个定值Intensity。我们在shader中设定好采样范围为0-10的立方体内部、采样步长距离StepDist、采样步长数量StepNum
half4 frag(Varyings IN) : SV_TARGET0
{
t
half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv);
float2 uv = IN.positionCS.xy / _ScaledScreenParams.xy;
#if UNITY_REVERSED_Z
real depth = SampleSceneDepth(uv);
#else
real depth = lerp(UNITY_NEAR_CLIP_VALUE, 1, SampleSceneDepth(uv));
#endif
float3 worldPos = ComputeWorldSpacePosition(uv, depth, UNITY_MATRIX_I_VP);
t
//RayMarching测试
float3 startPos =GetCameraPositionWS().xyz;
float3 rayDir = normalize(worldPos - GetCameraPositionWS().xyz);
float3 stepVec = rayDir * _StepDist;
float3 currentPos = startPos;
float totalIntensity = 0;
for(int i = 0; i < _StepNum; i++)
{
currentPos += stepVec;
if(currentPos.x > 0 && currentPtos.y > 0 && currentPos.z > 0 && currentPos.x < 10 && currentPos.y < 10 && currentPos.z < 10)
totalIndensity += _Intensity;
}
return albedo + totalIndensity;
}
但是此时我们会发现即使物体在雾效的前面,雾效也会把物体挡住
2.3 射线包围盒算法解决遮挡关系
计算射线与包围盒碰撞,判断有没有物体遮挡住体积云,如果有就不采样,如果没有就采样。这里是跟着文章里面使用的Nvidia公司研发的一种算法。
A Ray-Box Intersection Algorithm and Efficient Dynamic Voxel Rendering
这个算法返回两个值,一个是在体积云里面的线dstInsideBox(蓝),是要采样的;另一个是发射点到达体积云边界的线dstToBox(红)
现在我们加入一个物体(紫),绿色线为被遮挡后的实际射线
float dstToOpaque = length(worldPosition - GetCameraPositionWS().xyz)
我们可以通过dstToOpaque与红色线dstToBox进行比较,如果,红线 > 绿线,则物体挡住了包围盒;反之,包围盒挡住了物体,此时只要采样 绿线-红线 与 蓝色线较短的那一个。
float2 rayToBox(float3 boxMin, float3 boxMax, float3 rayDir, float3 rayOriPos)
{
float3 t0 = (boxMin - rayOriPos) / rayDir;
float3 t1 = (boxMax - rayOriPos) / rayDir;
float3 tmin = min(t0, t1);
float3 tmax = max(t0, t1);
float dstA = max(max(tmin.x, tmin.y),tim.z);
float dstB = min(min(tmax.x, tmax.y),tmax.z);
float dstToBox = max(0, dstA);
float dstInsideBox = max(0,dstB - dstToBox);
return float2(dstToBox, dstInsideBox);
}
然后可以得到dstToOpaque和dstLimit(实际采样)
dstToOpaque = length(worldPosition - GetCameraPositionWS().xyz);
dstLimit = min(dstToOpaque - dstToBox, dstInsideBox);
然后针对我们原来的参数也可以进行一些修改
- 发射起点:因为我们采样点还是在范围内,所以可以把从相机发射射线改为从体积云入口点发射。
float3 entryPoint = rayPos + rayDir * dstToBox;
- 发射步长:我们步长可以根据步数来进行调整
float stepDist = dstInsideBox / stepNum;
最后代码为这样
half4 frag(Varyings IN) : SV_TARGET0
{
half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv);
float2 uv = IN.positionCS.xy / _ScaledScreenParams.xy;
#if UNITY_REVERSED_Z
real depth = SampleSceneDepth(uv);
#else
real depth = lerp(UNITY_NEAR_CLIP_VALUE, 1, SampleSceneDepth(uv));
#endif
float3 worldPos = ComputeWorldSpacePosition(uv, depth, UNITY_MATRIX_I_VP);
float3 rayPos = GetCameraPositionWS().xyz;
float3 rayDir = normalize(worldPos - GetCameraPositionWS().xyz);
float2 rayBoxInfo = rayToBox(float3(-10,-10,-10), float3(0,0,0), rayDir, rayPos);
float dstToBox = rayBoxInfo.x; //红线
float dstInsideBox = rayBoxInfo.y; //蓝线
float dstToOpaque = length(worldPos - rayPos); //绿线
float dstLimit = min(dstToOpaque - dstToBox, dstInsideBox); //取绿-红和蓝的最小,作为采样距离
float stepDist = dstInsideBox / _StepNum;
float3 startPos =rayPos + rayDir * dstToBox;
float3 currentPos = startPos;
float intensity = 0.1 * stepDist;
float totalIntensity = 0;
float dstTravelled = 0; //累积采样距离,与dstLimit比较可以中断采样
for(int i = 0; i < _StepNum; i++)
{
if(dstTravelled < dstLimit)
{
totalIntensity += intensity;
}
else
{
break;
}
dstTravelled += stepDist;
}
return albedo + totalIntensity;
}
2.4 3D噪声实现浓度差异
我们需要几个参数来控制:三维纹理、噪声浓度、噪声偏移
float sampleNoise(float3 pos)
{
float3 uvw = pos * _NoiseScale + _NoiseOffset;
return tex3D(_NoiseMap, uvw).r;
}
然后我们通过对当前采样点的不断步进,来采样对应点的三维噪声纹理。(感谢王子饼干文章中提供的三维噪声纹理)
在运行的时候出现了循环迭代的报错。因为展开的迭代次数太多了,到一千次了,请使用 [unroll(n)] 特性来指定一个可能的最大值。所以在for循环前加了一个[unroll(40)]
指定最大循环数
half4 frag(Varyings IN) : SV_TARGET0
{
half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv);
float2 uv = IN.positionCS.xy / _ScaledScreenParams.xy;
#if UNITY_REVERSED_Z
real depth = SampleSceneDepth(uv);
#else
real depth = lerp(UNITY_NEAR_CLIP_VALUE, 1, SampleSceneDepth(uv));
#endif
float3 worldPos = ComputeWorldSpacePosition(uv, depth, UNITY_MATRIX_I_VP);
float3 rayPos = GetCameraPositionWS().xyz;
float3 rayDir = normalize(worldPos - GetCameraPositionWS().xyz);
float2 rayBoxInfo = rayToBox(float3(-10,-10,-10), float3(0,0,0), rayDir, rayPos);
float dstToBox = rayBoxInfo.x; //红线
float dstInsideBox = rayBoxInfo.y; //蓝线
float dstToOpaque = length(worldPos - rayPos); //绿线
float dstLimit = min(dstToOpaque - dstToBox, dstInsideBox); //取绿-红和蓝的最小,作为采样距离
float stepDist = dstInsideBox / _StepNum; //采样每步步长
float3 stepVec = stepDist * rayDir; //采样方向
float3 startPos =rayPos + rayDir * dstToBox; //其实采样点
float3 currentPos = startPos; //当前采样点
float intensity = 0.1 * stepDist; //采样点强度
float totalIntensity = 0; //累积强度
float dstTravelled = 0; //累积采样距离,与dstLimit比较可以中断采样
[unroll(40)]
for(int i = 0; i < _StepNum; i++)
{
if(dstTravelled < dstLimit)
{
totalIntensity += sampleNoise(currentPos);
}
else
{
break;
}
currentPos += stepVec;
dstTravelled += stepDist;
}
return albedo + totalIntensity;
}
2.5 摩尔消光系数
摩尔消光系数代表的是液体能见度,值越大,溶液越厚实,值越小,溶液越透彻
在体积云的情况下,它可以用于基于光学厚度可靠地计算透射率。
A
=
ϵ
c
l
A = \epsilon cl
A=ϵcl:
A
A
A 为吸光度,
ϵ
\epsilon
ϵ 为消光系数,
c
c
c 为溶液浓度,
l
l
l 为光线行走长度
对于我们上面的代码,totalIndensity就是溶液总浓度, 这个值越大,能见度越低,反之能见度越高,如果为0,那我们应该看不见云。所以我们使用exp(-x)函数来模拟
在shader中,我们添加一个消光系数_Absorption
[unroll(40)]
for(int i = 0; i < _StepNum; i++)
{
if(dstTravelled < dstLimit)
{
totalIntensity += sampleNoise(currentPos);
}
else
{
break;
}
currentPos += stepVec;
dstTravelled += stepDist;
}
return albedo *exp(-totalIntensity * _Absorption);
2.6 RayMarching 光照计算
因为我们是由屏幕空间发出的射线进行Raymarching,所以体积云最终的光照效果也就是每个方向的射线返回的光照。我们只需要关注一条射线的计算就好(因为GPU并行计算的特性)。
- 浓度关系:因为云的浓度不是均匀的,浓度高的地方透光就低,浓度低的地方透光就高。所以每个采样点的浓度都是不一样的。
- 接收到的光线强度:与点到光源的距离有关,距离远的光线强度就会较弱
- 光线传回视角方向的衰减:采样点被光源打到,然后传回视角方向(散射),还要经过一次云层的衰减,这次衰减的浓度就是在进行光线步进时的浓度。XB路径决定了它能接受多少能量,而XA路径则决定了它能散射出多少能量
所以对每个光线都要做一次积分计算光照,然后对于所有的光线又要做一次积分求和。
每个采样点累积浓度: ∫ 0 l x D y d y \int^{l_{x}}_{0}D_{y}dy ∫0lxDydy
//累积采样点浓度计算
float lightPathIntensity(float3 position, int stepCount)
{
Light light = GetMainLight();
float3 lightDir = _MainLightPosition.xyz;
float dstInsideBox = rayToBox(float3(-10,-10,-10), float3(10,10,10), 1/lightDir, position).y;
//采样
float stepSize = dstInsideBox / stepCount;
float totalIntensity = 0;
float3 stepVec = lightDir * stepSize;
for(int i = 0; i < stepCount; i++)
{
position += stepVec;
totalIntensity += max(0, sampleNoise(position) * stepSize);
}
return totalIntensity;
}
- 因为要得到云范围内的长度dstInsideBox来获得步长,这个采样距离因为我们的起始点用的是采样点的位置,所以方向也要是采样点到光源的方向,所以使用1/lightDir,实际上是在反转光线的方向——采样点到光源的方向。
对于光线强度计算
- 用exp函数来模拟浓度对光线的影响,exp内部的其实就是光线方向的总浓度,如果浓度为0,那么光线亮度值就是1,如果浓度为一个较大值,那么光线亮度就是很小的值。Dx为该采样点浓度,表达此处可以承受多少能量
- l x l_{x} lx 即采样点到光源的距离,dy 就是每一步步长。一条光线上的每个采样点都要做一次这样的积分。
- 然后由因为采样点的光线要返回观察点,所以也收到浓度的影响,Nx为反向浓度,C为摩尔消光系数
所以 此时 S = ∫ 0 l D x e x p ( − ∫ 0 l x D y d y ) e x p ( − N x C ) d x S = \int^{l}_{0} D_{x} exp(-\int^{l_{x}}_{0}D_{y}dy )exp(-N_{x} C) dx S=∫0lDxexp(−∫0lxDydy)exp(−NxC)dx
half4 frag(Varyings IN) : SV_TARGET0
{
Light light = GetMainLight();
//采样主纹理——renderTarget
half4 albedo = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv);
//重建世界坐标——使用NDC坐标
float2 uv = IN.positionCS.xy / _ScaledScreenParams.xy;
#if UNITY_REVERSED_Z
real depth = SampleSceneDepth(uv);
#else
real depth = lerp(UNITY_NEAR_CLIP_VALUE, 1, SampleSceneDepth(uv)); //平台差异化,OpenGL的NDC在[-1,1]
#endif
float3 worldPos = ComputeWorldSpacePosition(uv, depth, UNITY_MATRIX_I_VP);
//光线步进Info
float3 rayPos = GetCameraPositionWS().xyz;
float3 rayDir = normalize(worldPos - GetCameraPositionWS().xyz);
//碰撞盒体积计算
float2 rayBoxInfo = rayToBox(float3(-10,-10,-10), float3(10,10,10), rayDir, rayPos);
float dstToBox = rayBoxInfo.x; //红线
float dstInsideBox = rayBoxInfo.y; //蓝线
float dstToOpaque = length(worldPos - rayPos); //绿线
float dstLimit = min(dstToOpaque - dstToBox, dstInsideBox); //取绿-红和蓝的最小,作为采样距离
//浓度采样
float stepDist = dstInsideBox / _StepNum; //采样每步步长
float3 stepVec = stepDist * rayDir; //采样方向
float intensity = 0.1 * stepDist; //采样点强度
float stepCount = _StepNum; //采样总步数
float3 startPos =rayPos + rayDir * dstToBox; //起始采样点
float3 currentPos = startPos; //当前采样点
float totalIntensity = 0; //累积强度
float dstTravelled = 0; //累积采样距离,与dstLimit比较可以中断采样
float totalLightIntensity = 0; //累积光强
[unroll(40)]
for(int i = 0; i < stepCount; i++)
{
if(dstTravelled < dstLimit)
{
float Dx = sampleNoise(currentPos) * stepDist;
totalIntensity += Dx;
float pathIntensity = lightPathIntensity(currentPos, 8);
totalLightIntensity += exp(-(pathIntensity * _LightAbsorption + totalIntensity * _Absorption)) * Dx;
currentPos += stepVec;
dstTravelled += stepDist;
continue;
}
break;
}
float3 cloudColor = light.color.rgb * totalLightIntensity * _Color.rgb * _LightPower;
return float4(albedo *exp(-totalIntensity * _Absorption) + cloudColor, 1);
}
- 根据上述公式,我们首先计算每个采样点的强度Dx,然后像之前计算浓度影响一样得到totalIntensity——即Nx
- 然后计算光源到采样点的强度
- 再根据式子相乘,即可得到总的浓度,这里还加入了光线的消光系数 _LightAbsorption
- 再与云的颜色相乘得到云的光照计算,这里还加入光线的影响因子_LightPower
3. 完善散射
3.1 通过RGB通道模拟可见光散射波长
对于可见光来说,可以被分解为多种不同的色彩,从波长最长的红光到波长最短的蓝光。让光线撞到空气中的粒子时,会发生散射,蓝光的波长最短,最容易散射(这也是蓝天的原因),傍晚的时候又会出现粉色或者紫色的天空
(对于大气散射后面再单开一个详细学习吧)
在渲染时我们可以做一个小trick,在体积云浓度低的地方呈现蓝色,浓度高的地方呈现红色
主要通过设置一个RGB通道比率 σ \sigma σ 实现: T r = e − τ σ T_{r} = e^{-\tau\sigma} Tr=e−τσ
- e − τ e^{-\tau} e−τ 就是我们之间计算的透射比 exp(-totalIntensity * _Absorption)
- 当
σ
=
(
0.5
,
1
,
2
)
\sigma = (0.5, 1, 2)
σ=(0.5,1,2) 时,会出现整个云为红色
[unroll(40)]
for(int i = 0; i < stepCount; i++)
{
if(dstTravelled < dstLimit)
{
float Dx = sampleNoise(currentPos) * stepDist;
totalIntensity += Dx;
float pathIntensity = lightPathIntensity(currentPos, 8);
totalLightIntensity += exp(-(pathIntensity * _LightAbsorption + totalIntensity * _Absorption)* _Sigma) * Dx * _Sigma;
currentPos += stepVec;
dstTravelled += stepDist;
continue;
}
break;
}
3.2 相位函数——参与介质(Participating Media)计算模型
目前体积云的光照是根据光照方向来的,也就是说不同的观察方向光照效果并不会变化,这个叫做各向同性;但是实际的云在各个方向的观察结果应该是不同的,我们将通过相位函数来进行描述。
在A处观察应该是较暗的,在B处观察应该是更亮。这个图形的函数就是相位函数。不同的颗粒大小会采用不同的散射函数,主要有两类,瑞利散射和米氏散射,瑞利散射针对颗粒非常小的微粒(分子级别),通过用于大气散射理论,米氏散射一般针对较大的微粒(灰尘、云雾等)
模拟米氏散射一般采用比较精确的函数是 Hg Phase Aunction(亨利格林斯坦函数)
然后有一个简化版本的函数,Schlick Phase Function 施利克相位函数
这个
θ
\theta
θ就是射线方向与光源方向的夹角,这个phase值用于×在光的散射部分
//米氏散射——SchlickPhase
float SchlickPhaseFunc(float a, float g)
{
float k = 1.55 * g - 0.55 * g * g * g;
return (1.0 - k * k) / (12.56637 * (1 - k * a) * (1 - k * a));
}
//米氏散射——HgPhase
float HgPhaseFunction(float a, float g)
{
float g2 = g * g;
return (1 - g2) / (12.56637 * pow(1 + g2 - 2 * g * a, 1.5));
}
//相关参数计算,这个值可以在循环外计算
float cos_theta = dot(rayDir, light.direction);
float phase = SchlickPhaseFunc(cos_theta, _PhaseG);
[unroll(40)]
for(int i = 0; i < stepCount; i++)
{
if(dstTravelled < dstLimit)
{
float Dx = sampleNoise(currentPos) * stepDist;
totalIntensity += Dx;
float pathIntensity = lightPathIntensity(currentPos, 8);
totalLightIntensity += exp(-(pathIntensity * _LightAbsorption + totalIntensity * _Absorption)* _Sigma) * Dx * _Sigma * phase;
currentPos += stepVec;
dstTravelled += stepDist;
continue;
}
break;
}
当我们直面阳光的时候,会很亮,但当我们背对阳光的时候会很暗。
我们还可以通过引入双瓣相位函数来弥补背对太阳时的光照(就是基于两个函数之间进行插值),所以计算两个phase函数值就好
float cos_theta = dot(rayDir, light.direction);
float phase1 = SchlickPhaseFunc(cos_theta, _PhaseG1);
float phase2 = SchlickPhaseFunc(cos_theta, _PhaseG2);
float phase = lerp(phase1, phase2, 0.5);
3.3 Beer’s Power函数
对于实际的云,在边缘也会有较暗的边,这种效果我们称之为糖粉效应。所以我们需要在浓度低的地方也要进行衰减变暗,我们使用Beer’s Power函数来模拟这个效果(这里Tr是计算光穿过云层的透射率)
当a = 6时,其图像为
这里调整了下代码,将光传入云层的能量以及散射透射率计算分开,具体可以看注释
float transmittance = 1.0; //计算光穿过云层的透射率,计算衰减的
float3 totalLightIntensity = 0; //累积光线散射能量
float dstTravelled = 0; //累积采样距离,与dstLimit比较可以中断采样
//计算视角散射差异
float cos_theta = dot(rayDir, light.direction);
float phase1 = SchlickPhaseFunc(cos_theta, _PhaseG1);
float phase2 = SchlickPhaseFunc(cos_theta, _PhaseG2);
float phase = lerp(phase1, phase2, 0.5);
[unroll(40)]
for(int i = 0; i < stepCount; i++)
{
if(dstTravelled < dstLimit)
{
//采样点云的密度
float Dx = sampleNoise(currentPos) * stepDist;
//计算该点接受到的光能
float pathIntensity = lightPathIntensity(currentPos, 8);
float3 energy = BeerPowder(pathIntensity * _LightAbsorption * _Sigma,6) * Dx * _Sigma * phase;
//累加散射能量,*transmittance是考虑透射率的影响
totalLightIntensity += energy * transmittance;
//更新透射率
transmittance *= exp(-Dx * _Absorption * _Sigma);
//更新采样点
currentPos += stepVec;
dstTravelled += stepDist;
continue;
}
break;
}
float3 cloudColor = light.color.rgb * totalLightIntensity * _Color.rgb * _LightPower;
return float4(albedo * transmittance + cloudColor, 1);
4. 渲染体积云
4.1 不规则云层形状
我们需要让云朵边界部分变得更加不规则,所以可以使用两个噪声进行控制:一个是基于现有的威利3D噪声图来控制云团大形状,然后通过分形噪声对其进行侵蚀,来生成不规则的边界
分形噪声原理就是威利噪声、柏林噪声或者值噪声的多次叠加,每次叠加参数都不同。
用主浓度-侵蚀浓度,这个部分就可以得到较为不规则的形状
//采样3D噪声
float sampleNoise(float3 pos)
{
float3 noiseuvw = pos * _NoiseScale + _NoiseOffset;
float density = tex3D(_NoiseMap, noiseuvw).r;
float3 erodeuvw = pos * _ErodeScale + _ErodeOffset;
float erode = tex3D(_ErodeMap, erodeuvw).r * _ErodeDensity;
return max(0, density - erode) * _NoiseDensity;
//return tex3D(_NoiseMap, uvw).r;
}
4.2 范围盒边界衰减
我们这里还是很容易可以看出它的边界是长方体,所以我们需要增加两个新参数,一个控制xz轴的衰减,一个控制y轴的衰减。
具体算法是设立一个阈值,如果采样点到范伟盒的任意一边的距离低于阈值就衰减。重点就是计算其到边缘的距离,并检查是否超过了阈值,然后除以阈值的百分比值即可。
float sampleNoise(float3 pos)
{
float3 noiseuvw = pos * _NoiseScale + _NoiseOffset;
float density = tex3D(_NoiseMap, noiseuvw).r;
float3 erodeuvw = pos * _ErodeScale + _ErodeOffset;
float erode = tex3D(_ErodeMap, erodeuvw).r * _ErodeDensity;
//包围盒边界衰减
float edgeToX = min(_EdgeThreshold.x, min(pos.x - _VolumeMin.x, _VolumeMax.x - pos.x));
float edgeToZ = min(_EdgeThreshold.x, min(pos.z - _VolumeMin.z, _VolumeMax.z - pos.z));
float edgeToY = min(_EdgeThreshold.y, min(pos.y - _VolumeMin.y, _VolumeMax.y - pos.y));
float softness = edgeToX / _EdgeThreshold.x * edgeToZ / _EdgeThreshold.x * edgeToY / _EdgeThreshold.y;
return max(0, density - erode) * _NoiseDensity * softness * softness;
//return tex3D(_NoiseMap, uvw).r;
}
4.3 进行多次散射模拟
解决现在太暗和过曝问题,文章中是利用了寒霜引擎在技术分享时提到的一个多次散射。S为我们现有的散射结果,然后对主光源进行多次基于
α
\alpha
α的衰减,然后将衰减结果相加在一起。(可以直接计算积分,不用真的循环计算)
这样就可以得到一个简单体积云了。
5. 优化
5.1 双边滤波(待更新)
参考
对愿意出教程的所有大佬们永远感恩
体积云渲染(Volumetric Clouds),技术美术教程 - 异世界的魔法石的文章 - 知乎
Shader魔法学笔记.RayMarching体积云URP实现.上 - 王子饼干的文章 - 知乎
Shader魔法学笔记.RayMarching体积云URP实现.下 - 王子饼干的文章 - 知乎
Reconstruct the world space positions of pixels from the depth texture - Unity官方文档