体积雾本质上模拟的是参与介质(Participating Media)——光在穿过雾这种介质时会发生两件事:
透射:光被介质吸收或散射掉,能穿透的越来越少。这决定了你透过雾看场景时场景会变暗变模糊。
散射:介质中的粒子把周围的光(太阳光、灯光)散射进你的视线,这就是雾自己”发光”的原因。没有散射,雾只会让场景变暗,不会有光轴效果。
所以体积雾看起来有体积感,本质是这两个过程沿射线逐步累积的结果——不同位置密度不同,透射和散射的贡献也不同,最终叠加出有层次的体积感。
雾密度
雾密度(density)就是描述空间中某一点雾有多浓,是一个标量值。 最简单的几种算法从易到难:
均匀密度
整个空间密度一样,最简单:
float density = _FogDensity;
高度雾(指数衰减)
越低密度越大,符合自然雾沉在地面的感觉:
float density = _FogDensity * exp(-currentPos.y * _FogFalloff);
_FogFalloff控制密度随高度衰减的速度。
噪声密度(3D噪声纹理)
让雾有云团感,不均匀
float noise = tex3D(_NoiseTex, currentPos * _NoiseScale + _Time.y * _WindDir).r;
float density = noise * _FogDensity;
高度雾 + 噪声(最常用)
两者相乘,既有高度分层又有体积云团感:
float heightFog = exp(-currentPos.y * _FogFalloff);
float noise = tex3D(_NoiseTex, currentPos * _NoiseScale).r;
float density = _FogDensity * heightFog * noise;
比尔-朗伯定律
从公式来看就是指数衰减,然后指数是密度乘以每段的距离dx然后加个负号


基础框架
需要先计算每次步进的距离
float3 rayStart = _WorldSpaceCameraPos;
float3 rayEnd = worldPos.xyz;
float3 rayDir = normalize(rayEnd - rayStart);
float rayLength = length(rayEnd - rayStart);
然后计算透射率
// 透射率累积 extinction ratio 消光比
float extinction = density * stepSize;
transmittance *= exp(-extinction);
然后就是散射光积累和混合场景颜色,这里先不接入场景光源颜色,直接给1,白色光

当前效果

追加光源影响,现在光源是偏黄的



相位函数

**当前效果是雾从四面八方都是一样的密度,但是往各个方向散射的概率分布应该是不一样的,比如顺光侧和逆光侧,逆光时雾应该更明显,顺光则更微弱。
float HenyeyGreenstein(float cosTheta, float g)
{
float g2 = g * g;
return (1.0 - g2) / (4.0 * PI * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5));
}
加入到散射光计算中

当前效果如下,当前并没有上帝光的效果,或者说丁达尔的效果,这需要加上阴影的采样

阴影采样(上帝光)
在每次步进的时候对阴影进行采样,这样就可以得到上帝光效果了

当级联阴影层数的前两层差距几乎没有的时候,这时候低步进次数比如16,效果最为平滑,但是阴影会更模糊,但是治标不治本,这里只是一种调整方法

效果如下
正常情况下,层级应该是递增的

这样步进为16和32,中间阴影都会出现些问题,有伪影产生
这里文档中也提到了这个问题

这里的解决方法是指数级的采样shadowmap,比较复杂我这里没有实现,暂时留个坑

jitter抖动偏移
为了缓解伪影的问题,这里对每次采样起点进行jitter,这个方法在RTR4中也有提及


可以看到jitter后伪影得到缓解,但是会有微小噪点

蓝噪声,这些噪点过于明显。所以使用蓝噪声来进行优化

其实就是直接采样蓝噪声贴图就行 贴图下载地址是https://momentsingraphics.de/BlueNoise.html

可以看到感知的噪点减少,也更自然,当然噪点还是不能有的,就需要像TAA那样进行时域累积,来消除微小噪点,这里暂未实现,后续补充

关于使用jitter的问题,主要还是解决采样步进次数过少,伪影的问题,也是为了节省性能,如果不用jitter,明显是步进次数越多越好的,直接调整步数就好

半分辨优化
当前步进次数和开销在4k下开销很大,这里将雾效的计算降到半分辨率,在混合回来

优化后,16次的步进在1ms左右

一般都是使用32次步进以上,这里32次步进耗时还可以

增加3DNoise
防止密度过于均匀,这里添加了一个3d体积的Noise进行扰动,用ai写了一个生成3d纹理的脚本,这个放在最后

大致效果如下

添加环境光
对于环境光,我直接使用最简单的方法,给定颜色方便调整,没有使用内置,因为内置的天光调整比较烦人。

直接加到散射积累中就行

效果大致就是这样,给暗部加点亮度

对于文档中提到了使用SH,我这里没有使用,因为是作用于整个场景,需要手动传入场景的SH系数,不能像物体shader一样直接使用SH内置函数,使用SH物理更正确,当然开销也更大一点

关于多光源的问题
我尝试在Shader中获取多光源,但是发现根本没有数据,后面查到GetAdditionalLightsCount()和GetAdditionalLight()是per-object设置的,在对象渲染阶段由Unity填充,后处理Pass里这些数据根本不可用。所以解决办法只能从C#进行传入,这个暂时没有添加,后续在补**
最后效果就是这样,这里密度比较低,不过能看见光轴

浓度调高,侧面对比遮阳部分效果更明显一些

核心代码
// Pass 0: CustomVolumetricFog
Pass
{
Name "CustomVolumetricFog"
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma multi_compile _ _ADDITIONAL_LIGHTS //开启多光源计算
// 相位函数
float HenyeyGreenstein(float cosTheta, float g)
{
float g2 = g * g;
return (1.0 - g2) / (4.0 * PI * pow(1.0 + g2 - 2.0 * g * cosTheta, 1.5));
}
float4 frag(Varyings input) : SV_Target
{
float2 uv = input.uv;
float rawDepth = SampleSceneDepth(uv);
float depth = LinearEyeDepth(rawDepth, _ZBufferParams);
if (rawDepth == 0.0)
{
return SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv);
}
//利用从 C# 传过来的 _InverseProjection 重建观察空间坐标
float4 ndc = float4(uv * 2.0 - 1.0, rawDepth, 1.0);
float4 worldPos = mul(_InverseVP, ndc);
worldPos /= worldPos.w;
// ======== 射线步进 ========
// 射线终点:worldPos.xyz(当前像素对应的世界坐标)
// 射线起点:相机世界坐标_WorldSpaceCameraPos(URP内置变量,不需要额外传)
float3 rayStart = _WorldSpaceCameraPos;
float3 rayEnd = worldPos.xyz;
float3 rayDir = normalize(rayEnd - rayStart);
float rayLength = length(rayEnd - rayStart);
// 步进
//int stepCount = 64;
float stepSize = rayLength / (float)SAMPLE_COUNT;
float3 currentPos = rayStart;
float3 scatteredLight = 0;
float transmittance = 1.0;
// ===== 环境光 =====
// float3 ambient = unity_AmbientSky.rgb * _AmbientIntensity; 内置skyColor
float3 ambient = _AmbientColor.xyz * _AmbientIntensity;
if(_UseStepJitter)
{
// 在步进之前加随机偏移
//float jitter = frac(sin(dot(uv, float2(12.9898, 78.233))) * 43758.5453);
float2 noiseUV = input.uv * _ScreenParams.xy * _BlueNoiseTex_TexelSize.xy;
float jitter = SAMPLE_TEXTURE2D(_BlueNoiseTex, sampler_BlueNoiseTex, noiseUV * _BlueNoiseTileScale).r;
currentPos += rayDir * stepSize * jitter;
}
for (int i = 0; i < SAMPLE_COUNT; i++)
{
currentPos += rayDir * stepSize;
// 密度(高度雾)
float density = exp(-currentPos.y * _FogFalloff) * _FogDensity;
float noise = SAMPLE_TEXTURE3D(_NoiseTex, sampler_NoiseTex, currentPos * _NoiseScale).r;
density *= lerp(1.0, noise, _NoiseStrength);
//float density = max(0, _FogDensity * exp(-currentPos.y * _FogFalloff));
// 透射率累积
float extinction = density * stepSize;
// 阴影和光照
float4 shadowCoord = TransformWorldToShadowCoord(currentPos);
float shadow = MainLightRealtimeShadow(shadowCoord);
Light mainLight = GetMainLight(shadowCoord);
float3 lightDir = mainLight.direction; // 光源方向
float3 lightColor = mainLight.color * shadow; // 光源颜色
// 相位函数影响
float cosTheta = dot(rayDir, mainLight.direction);
float phase = HenyeyGreenstein(cosTheta, _Anisotropy);
// 环境光影响
float3 ambientScatter = ambient * (1.0 / (4.0 * PI));
// 散射光累积(白色光亮度为1) scatteredLight += density * stepSize * transmittance * LightColor;
scatteredLight += density * stepSize * transmittance * (lightColor * phase + ambientScatter);
// 透射率累积 更新透射率
transmittance *= exp(-extinction);
if(transmittance < 0.01) break;
}
float3 result = (scatteredLight * _FogColor.rgb) * _ScatterIntensity;
return float4(result, transmittance);
}
ENDHLSL
}
3DNoise生成脚本
using UnityEngine;
using UnityEditor;
public class Texture3DGenerator : EditorWindow
{
private int resolution = 64;
private float perlinScale = 4f;
private float worleyScale = 3f;
private float worleyWeight = 0.5f;
private string savePath = "Assets/Textures/FogNoise3D.asset";
[MenuItem("Tools/Generate Fog Noise 3D")]
public static void ShowWindow()
{
GetWindow<Texture3DGenerator>("Fog Noise 3D Generator");
}
private void OnGUI()
{
GUILayout.Label("3D Noise Settings", EditorStyles.boldLabel);
resolution = EditorGUILayout.IntField("Resolution", resolution);
perlinScale = EditorGUILayout.FloatField("Perlin Scale", perlinScale);
worleyScale = EditorGUILayout.FloatField("Worley Scale", worleyScale);
worleyWeight = EditorGUILayout.Slider("Worley Weight", worleyWeight, 0f, 1f);
savePath = EditorGUILayout.TextField("Save Path", savePath);
if (GUILayout.Button("Generate"))
{
GenerateTexture3D();
}
}
private void GenerateTexture3D()
{
Texture3D texture = new Texture3D(resolution, resolution, resolution, TextureFormat.RFloat, false);
texture.wrapMode = TextureWrapMode.Repeat;
texture.filterMode = FilterMode.Bilinear;
Color[] colors = new Color[resolution * resolution * resolution];
for (int z = 0; z < resolution; z++)
for (int y = 0; y < resolution; y++)
for (int x = 0; x < resolution; x++)
{
float u = (float)x / resolution;
float v = (float)y / resolution;
float w = (float)z / resolution;
float perlin = SamplePerlin3D(u * perlinScale, v * perlinScale, w * perlinScale);
float worley = SampleWorley3D(u * worleyScale, v * worleyScale, w * worleyScale);
// Worley取反让云团是亮的
float value = Mathf.Lerp(perlin, 1f - worley, worleyWeight);
value = Mathf.Clamp01(value);
int idx = x + y * resolution + z * resolution * resolution;
colors[idx] = new Color(value, 0, 0, 0);
}
texture.SetPixels(colors);
texture.Apply();
AssetDatabase.CreateAsset(texture, savePath);
AssetDatabase.SaveAssets();
Debug.Log($"3D Noise Texture saved to {savePath}");
}
// 3D Perlin噪声(用Unity内置,叠加三轴)
private float SamplePerlin3D(float x, float y, float z)
{
float xy = Mathf.PerlinNoise(x, y);
float yz = Mathf.PerlinNoise(y, z);
float xz = Mathf.PerlinNoise(x, z);
float yx = Mathf.PerlinNoise(y, x);
float zy = Mathf.PerlinNoise(z, y);
float zx = Mathf.PerlinNoise(z, x);
return (xy + yz + xz + yx + zy + zx) / 6f;
}
// Worley噪声(细胞噪声)
private float SampleWorley3D(float x, float y, float z)
{
int ix = Mathf.FloorToInt(x);
int iy = Mathf.FloorToInt(y);
int iz = Mathf.FloorToInt(z);
float minDist = float.MaxValue;
for (int dz = -1; dz <= 1; dz++)
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
{
Vector3 cellOrigin = new Vector3(ix + dx, iy + dy, iz + dz);
Vector3 rand = Random3(cellOrigin);
Vector3 featurePoint = cellOrigin + rand;
float dist = Vector3.Distance(new Vector3(x, y, z), featurePoint);
minDist = Mathf.Min(minDist, dist);
}
return Mathf.Clamp01(minDist);
}
private Vector3 Random3(Vector3 p)
{
float x = Mathf.Abs(Mathf.Sin(Vector3.Dot(p, new Vector3(127.1f, 311.7f, 74.7f)))) * 43758.5453f;
float y = Mathf.Abs(Mathf.Sin(Vector3.Dot(p, new Vector3(269.5f, 183.3f, 246.1f)))) * 43758.5453f;
float z = Mathf.Abs(Mathf.Sin(Vector3.Dot(p, new Vector3(113.5f, 271.9f, 124.6f)))) * 43758.5453f;
return new Vector3(x - Mathf.Floor(x), y - Mathf.Floor(y), z - Mathf.Floor(z));
}
}