Hi!大家好我是Kevin,来自畅游引擎部。这次分享的内容是RayMarching体积雾渲染的实现。
在户外游戏场景的表现中,雾效对整体氛围的渲染有着至关重要的作用。本次使用了Unity3D引擎,采用的URP管线。首先URP管线是支持内置雾效的,可以通过Window-Rendering-Lighting-Other Settings中的fog来进行开启和调整。
思路基本与后处理线性雾的思路,通过深度图还原世界空间坐标,然后分别通过高度和深度决定雾的强度。该方法虽然简单但对于区域控制以及真实性动态性方面表现不足,所以我们选择体积雾的方案,本文将具体介绍体积雾的实现。
RayMarching原理
RayMarching是目前较为完善的实时体渲染算法,具体原理已经有很多教程了,可以看一下FMX的演讲,很有参考价值。
本质上是在着色器内进行循环,从相机向每个像素发射射线,按照射线方向进行步进,按照其坐标进行采样计算,最后叠加混合得到渲染结果。
具体实施到后处理体积雾,从相机的世界空间坐标开始,设定一个最大的影响距离和步进次数,通过计算获取光线步进的方向,就可以计算出步进后的世界坐标,进而计算出与之对应的颜色,并在该像素上进行叠加混合,最终达到体积雾的效果。
本次采用的URP管线,后处理的方式是通过RenderFeature和Volume进行实现,思路上采用RayMarching的整体思路进行实现。
获取步进后的世界坐标
首先我们通过UV计算出世界空间的步进的方向(即光线的方向),主要是通过将UV转换为NDC坐标,然后再转换为齐次坐标,最后进行矩阵运算将坐标转为摄像机空间,最后转换为世界空间坐标。
float3 viewVector = mul(unity_CameraInvProjection, half4(UV * 2 - 1, 0.0, -1)).xyz;
viewVector = mul(unity_CameraToWorld, half4(viewVector, 0.0)).xyz;
float viewLength = length(viewVector);
rayDirection = viewVector / viewLength;
然后按照*RayMarching*的思路,在shader中执行循环步进并叠加颜色,步进的起点是camera的世界空间坐标,每次步进的步长可以通过最大的影响距离/步进次数计算得出,乘以步进方向得到步进后的世界坐标。
Step = maxDistance * rcp(Count);
for (int i = 1; i <= Count; i++)
{
rayDepth = i * Step;
rayPosition = cameraPosition + rayDirection * rayDepth;
Color += CalColor(rayPosition);
}
通过世界坐标计算雾效强度
在获得了步进后的世界坐标后,可以通过坐标计算出该点对应的雾效,雾效主要分为强度和颜色两个部分,强度主要通过高度深度、选定区域以及噪声三个部分决定。
高度
与线性雾中高度雾的实现方式相同,获取世界坐标的y,通过其在规定的最大和最小高度之间的比例,确定雾的强度。
half GetFogDensityByHeight(half MaxHeight, half BaseHeight, half3 RayPosition)
{
half height = max(RayPosition.y - BaseHeight, 0.0);
return pow(height / max(MaxHeight-BaseHeight, 0.0),5);
}
当然这种方法的可控性是不足的,无法做到自定义高度控制并且不够平滑,所以我决定采用曲线,用于根据高度调整雾的强度,不同的高度对应于自定义的强度。
但是最终渲染实现是在shader内进行的,如何将VolumeComponent中的Curve信息传到shader内就成为了一个问题,因为RenderFeature是不支持将 AnimationCurve 直接传递到shader内的,所以我们这里将Curve信息读出来写入纹理,并通过纹理的形式传入shader内。
Texture2D CreateCurveTexture(AnimationCurve curve)
{
int resolution = 256;
Texture2D texture = new Texture2D(resolution, 1, TextureFormat.RGBA32, false, true);
texture.filterMode = FilterMode.Bilinear;
texture.wrapMode = TextureWrapMode.Clamp;
for (int i = 0; i < resolution; i++)
{
float t = i / (float)(resolution - 1);
float value =curve.Evaluate(t);
Color color = new Color(value, value, value, 1f);
texture.SetPixel(i,0,color);
}
texture.Apply();
return texture;
}
然后将原来的数学乘方计算更改为纹理采样即可。
half GetFogDensityByHeight(half MaxHeight, half BaseHeight, half3 RayPosition)
{
half height = max(RayPosition.y - BaseHeight, 0.0);
half heightFactor = height / max(MaxHeight-BaseHeight, 0.0);
return SAMPLE_TEXTURE2D_LOD (_HeightFogCurveTexture,sampler_HeightFogCurveTexture,half2(heightFactor,0),0).r;
}
得到的效果与高度雾基本相同
选定区域(局部雾)
自然环境中,也不定都是被云雾弥漫的现象,我们可以利用得到的世界坐标,对雾的位置进行限定。
我们这里简单的选用了易于计算的立方体作为我们区域的限制区域形状(其他形状也可以选用,但需要额外的判断算法)为了易于调节,我们通过Gizmos设定了一个限定区域。
Gizmos.color = Color.red;
Gizmos.DrawWireCube(transform.position, Size);
在定义好限定区域后将区域信息相关数据通过RenderFeature传入shader中(正常调用流程,此处省略),在shader中我们针对区域信息建立struct进行接收。
struct Box
{
float3 position;
float3 size;
};
在获取了区域信息后,采用AABB算法计算出步进点到Box边缘的有向距离distance,在Box外为正数,在Box内为负数,如果在BOX内则进行渲染。
half CalBox(float3 rayPosition, Box box)
{
half3 v = abs(rayPosition - box.position) - box.size * 0.5;
return length(max(v, 0)) + min(max(v.x, max(v.y,v.z)), 0);
}
half GetFogDensityByMask(float3 RayPosition)
{
half intensity = 0.0;
for (int i = 0; i < _VolumeMaskCount; i++)
{
float distance = 0;
Box box;
box.position = _VolumeMaskPosition[i].xyz;
box.size = _VolumeMaskSize[i].xyz;
distance = CalBox(RayPosition, box);
intensity = step(distance,0);
}
return intensity;
}
可以看出雾效被局限在选定区域内了,但是整体强度相同,导致边缘存在突兀的问题,效果并不真实,我们可以利用我们计算出的距离distance做平滑过渡,可以得到一个边缘过渡的较为平滑的效果。
half GetFogDensityByMask(float3 RayPosition)
{
half intensity = 0.0;
for (int i = 0; i < _VolumeMaskCount; i++)
{
float distance = 0;
Box box;
box.position = _VolumeMaskPosition[i].xyz;
box.size = _VolumeMaskSize[i].xyz;
distance = CalBox(RayPosition, box);
//映射到1-0
half t = distance/_VolumeExclusiveDistance[i];
half attenuation = lerp(1,0,saturate(t));
intensity += attenuation;
}
return intensity;
}
噪声
现在有了基础的形态,但由于密度是均匀的,还是会不自然,我们引入三维噪声进行处理,主要思路就是通过采样三维噪声纹理,获取强度数据,以达到较为自然的区域内随机效果。
这里使用目前较为常用的Perlin-Worley噪声,它是两个较为经典的体积渲染的噪声,Perlin噪声和Worley噪声的混合,兼顾了随机性以及动态效果。
Perlin噪声
主要思路是将区域划分为若干个晶格,获取该坐标所在晶格,然后通过该点与晶格顶点坐标计算出随机点到晶格各个顶点的向量,然后将他们分别与各自顶点上的梯度向量进行点乘。
Worley噪声
主要思路是将空间分别为若干个正方体区域,在每个区域内随机生成一个位置随机的特征点,那么计算某个像素的数值时仅需要距离最近特征点的距离,即可得到该像素的值。
具体详细实现方法可以参考:(然后通过Compute shader实现)
【shader】超级噪声库,附代码(fbm、Perlin、Simplex、Worley、Tiling、Curl等,很全很全)
这么我们直接使用一个生成好的三维Perlin-Worley噪声。
在得到了三维Perlin-Worley噪声之后,我们需要对其进行采样并加入风力的影响因素,然后对采样结果降低维度,映射到设定范围内。
half InverseLerp(half start, half stop, half value)
{
return (value - start) / (stop - start);
}
half Remap(half inStart, half inStop, half outStart, half outStop, half v)
{
half t = InverseLerp(inStart, inStop, v);
return lerp(outStart, outStop, saturate(t));
}
half GetFogNoise(Texture3D NoiseTexture, SamplerState Sampler, half3 RayPosition, half NoiseScale, half3 WindVelocity, half NoiseMin, half NoiseMax)
{
half3 uvw = RayPosition * NoiseScale;
half3 wind = WindVelocity * NoiseScale * _Time.y;
half4 value = 0;
value = amp * SAMPLE_TEXTURE3D(NoiseTexture, Sampler, uvw + wind);
half v = value.r * 0.53 + value.g * 0.27 + value.b * 0.13 + value.a * 0.07;
v = Remap(NoiseMin, NoiseMax, 0.0, 1.0, v);
return v;
}
最后将以上三个因素相乘得到重建世界坐标对应雾效的强度。
half density = GetFogDensityByHeight(_MaxHeight, _BaseHeight, rayPosition) * GetFogDensityByNoise(_NoiseTexture, linear_repeat_sampler, rayPosition, NoiseScale, _NoiseWindSpeed, _NoiseIntensityMin, _NoiseIntensityMax)* GetFogFalloff(rayPosition);
光照计算
与普通的模型渲染不同,在体渲染中,我们还需要模拟光线穿过介质后发生的变化,一般在穿过介质的过程中都会出现散射和吸收导致能量发生变化。
散射
由于雾的内部是不均匀的,所以各个位置的散射是不同的,与光源和步进方向夹角相关。我们可以用HG相位函数进行表示(其中g为非对称参数):
half HenyeyGreenstein(half g)
{
half cosAngle = dot(normalize(mainLightDirection), normalize(rayDirection));
half g2 = g * g;
return ((1.0 - g2) / pow(abs(1.0 + g2 - 2.0 * g * cosAngle), 1.5)) / 4.0 * 3.1416;
}
在获得了散射参数后,通过世界坐标计算出shadow,混合散射后的亮部和暗部的颜色。
half hg = HenyeyGreenstein(_Anisotropy);
half GetShadowAttenuation(half3 RayPos)
{
half4 shadowCoord = TransformWorldToShadowCoord(RayPos);
return MainLightRealtimeShadow(shadowCoord);
}
half shadowAttenuation = GetShadowAttenuation(rayPosition);
half3 fogColor = lerp(shadedColor, litColor * hg, shadowAttenuation) + emitColor;
吸收
介质中会存在光线被吸收的情况,我们可以通过透光率(transmittance)来进行表示在一定距离内能够通过介质的光的比率:
其中τ表示光学深度(减量),与雾的浓度和光想行进距离正相关
half transmittance = exp(-density * stepLength);
在计算出透光率后完成颜色、透明度的计算和叠加
half3 inScatteringData = density * fogColor ;
half3 scattering = inScatteringData * (1 - transmittance)) / density;
Color += Alpha * scattering ;
Alpha *= transmittance ;
在添加了光照计算之后,得到不错的局部体积雾渲染效果。
接受其他灯光照明
之前的介绍全部都是针对主光源mainlight的,但是场景中还会存在其他光源(尤其是夜晚),目前其他光源(如Point/Spot Light)无法对体积雾产生影响。
我们可以在Light上添加脚本获取Light的相关信息并通过RenderFeature传入shader中,遍历所有AdditionalLight,并计算出其在步进点的影响强度并进行叠加。
half3 GetAdditionalLightData(half3 RayPosition)
{
half3 additionalLightsColor = half3(0,0,0);
int lightCount = _LightCount;
for (int i = 0; i < _LightCount; i++)
{
half3 lightColor =
_LightColor[i]
* _LightIntensity[i]
* GetLightFalloff(RayPosition, _LightPos[i], _LightAngle[i], _LightDirection[i]);
additionalLightsColor += lightColor;
}
return additionalLightsColor;
}
其强度与AdditionalLight光的颜色和强度相关,并且也会随着角度和距离衰减。
角度衰减:
计算出光源到该点的向量,并求出该方向与AdditionalLight方向的夹角,最后与内外锥角进行运算,确保其在AdditionalLight的内锥角和外锥角范围内进行衰减。
距离衰减:
计算出光源到该点的距离,然后使用简单的距离平方倒数计算,表示光源距离的衰减。
最后将两个部分衰减进行相乘。
half GetLightFalloff(half3 RayPosition, half3 LightPosition, half2 LightAngle, half3 LightForward)
{
half3 dirToRay = normalize(LightPosition - RayPosition);
half dotDir = dot(dirToRay, LightForward);
half spotAttenuation = saturate((dotDir * LightAngle.x) + LightAngle.y);
spotAttenuation *= spotAttenuation;
half d = distance(RayPosition, LightPosition);
half distanceAttenuation = 1.0 / ((1.0 + (d * d)));
return distanceAttenuation * spotAttenuation;
}
将得到的结果添加到总体的光照计算之中
half3 additionalLightColor = GetAdditionalLightData(rayPosition);
half3 inScatteringData = density * (fogColor + additionalLightColor);
最终的效果还不错。
更换一个场景做一个近景的demo
欢迎加入我们!
感兴趣的同学可以投递简历至:CYouEngine@cyou-inc.com