我们在之前做过的案例:Vulkan_动态地形细分(Tessellation Shader)(本次特效也是基于此场景实现)中介绍过简单的雾特效,通过它可以模拟很多现实世界中与雾、烟等相关的场景。但是简单的雾特效也有一定的局限性,如在实现山中烟雾缭绕的效果时就比较假。这是由
于简单的雾特效没有考虑到变化的情况,只是采用简单的与距离相关的公式计算雾浓度因子。
而现实世界中的山中雾气往往是随风变化的,并不是在所有的位置都遵循完全一致的雾浓度因子计算公式。本此我们来看一种能更好地模拟山岚烟云效果的雾特效技术——体积雾,通过它可以开发出非常真实的山中烟雾缭绕的效果。
一、基本原理
介绍具体的案例之前,首先需要了解本节案例实现体积雾的基本原理。体积雾实现的关键在于计算出每个待绘制片元的雾浓度因子,然后根据雾浓度因子、雾的颜色及片元本身采样的纹理颜色计算出片元的最终颜色。
你可能会有疑问:简单的雾特效采用的不也是这样的策略吗?确实如此,基本的大思路很类似,但体积雾雾浓度因子的计算模型不像简单的雾特效那样是一个简单呆板的公式,具体的计算策略如图所示。
从图中可以看出,体积雾具体的计算策略如下(此计算由片元着色器完成):
- 首先通过当前待处理片元的位置与摄像机的位置确定一根射线,并求出射线与雾平面的交点位置。
- 若上述交点在雾平面以下,则求出交点到待处理片元位置的距离。
- 根据此距离的大小求出雾浓度因子,距离越大雾越浓。
为了进一步增加真实感,实际案例中的雾平面并不是一个完全的平面,而是加入了正弦函数的高度扰动使得雾平面看起来有波动效果,如上图中右侧所示。(可参见Vulkan_顶点着色器特效1(流动的水面或飘扬的红旗)实现原理)
二、开发步骤
我们主要是实现了两种雾化,一个是远处场景雾化,还有一个是低洼处体积雾动态效果,可见下边两幅图:
具体片元着色器如下:
#version 450
layout (set = 0, binding = 1) uniform sampler2D samplerHeight;
layout (set = 0, binding = 2) uniform sampler2DArray samplerLayers;
layout (set = 0, binding = 3) uniform UBOCON //体积雾控制参数
{
vec4 uCamaraLocation;//摄像机位置
float slabY ;//雾平面的高度
float startAngle;//正弦函数波动起始角度
float qFheight;//雾平面起伏高度系数
} uboCon;
layout (location = 0) in vec3 inNormal;
layout (location = 1) in vec2 inUV;
layout (location = 2) in vec3 inViewVec;
layout (location = 3) in vec3 inLightVec;
layout (location = 4) in vec3 inEyePos;
layout (location = 5) in vec3 inWorldPos;
layout (location = 0) out vec4 outFragColor;
//根据地形高度获取不同纹理
vec3 sampleTerrainLayer()
{
vec2 layers[6];
layers[0] = vec2(-10.0, 10.0);
layers[1] = vec2(5.0, 45.0);
layers[2] = vec2(45.0, 80.0);
layers[3] = vec2(75.0, 100.0);
layers[4] = vec2(95.0, 140.0);
layers[5] = vec2(140.0, 190.0);
vec3 color = vec3(0.0);
float height = textureLod(samplerHeight, inUV, 0.0).r * 255.0;
for (int i = 0; i < 6; i++)
{
float range = layers[i].y - layers[i].x;
float weight = (range - abs(height - layers[i].y)) / range;
weight = max(0.0, weight);
color += weight * texture(samplerLayers, vec3(inUV * 16.0, i)).rgb;
}
return color;
}
//远方雾化
float fog(float density)
{
const float LOG2 = -1.442695;
float dist = gl_FragCoord.z / gl_FragCoord.w * 0.1;
float d = density * dist;
return 1.0 - clamp(exp2(d * d * LOG2), 0.0, 1.0);
}
//动态体积雾
float tjFogCal(vec4 pLocation){//计算体积雾浓度因子的方法
float slabY=uboCon.slabY; //体积雾对应雾平面的高度
float startAngle=uboCon.startAngle; //扰动起始角
float xAngle=pLocation.x/16.0*3.1415926;//计算出顶点X坐标折算出的角度
float zAngle=pLocation.z/20.0*3.1415926;//计算出顶点Z坐标折算出的角度
float slabYFactor=sin(xAngle+zAngle+startAngle)*uboCon.qFheight;//联合起始角计算出角度和的正弦值
//求从摄像机到待处理片元的射线参数方程Pc+(Pp-Pc)t与雾平面交点的t值
float t=(slabY+slabYFactor-uboCon.uCamaraLocation.y)/(pLocation.y-uboCon.uCamaraLocation.y);
//有效的t的范围应该在0~1的范围内,若不存在范围内表示待处理片元不在雾平面以下
if(t > 0.0 && t < 1.0){//若在有效范围内则
//求出射线与雾平面的交点坐标
float xJD=uboCon.uCamaraLocation.x+(pLocation.x-uboCon.uCamaraLocation.x)*t;
float zJD=uboCon.uCamaraLocation.z+(pLocation.z-uboCon.uCamaraLocation.z)*t;
vec3 locationJD=vec3(xJD,slabY,zJD);
float L=distance(locationJD,pLocation.xyz);//求出交点到待处理片元位置的距离
float L0=10.0;
return L0/(L+L0);//计算体积雾的雾浓度因子
}else{
return 1.0;//若待处理片元不在雾平面以下,则此片元不受雾影响
}
}
void main()
{
vec3 N = normalize(inNormal);
vec3 L = normalize(inLightVec);
vec3 ambient = vec3(0.5);
vec3 diffuse = max(dot(N, L), 0.0) * vec3(1.0);
vec4 color = vec4((ambient + diffuse) * sampleTerrainLayer(), 1.0);
const vec4 fogColor = vec4(0.47, 0.5, 0.67, 0.0);
//远处雾化
vec4 finalColor = mix(color, fogColor, fog(0.25));
//计算雾浓度因子
float fogFactor=tjFogCal(vec4(inWorldPos,1));
//根据雾浓度因子、雾的颜色及片元本身采样的纹理颜色计算出片元的最终颜色
outFragColor=fogFactor*finalColor+ (1.0-fogFactor)*fogColor; //给此片元最终颜色值
}
- 本案例中最有代表性的、根据传入着色器的参数计算体积雾浓度因子的tjFogCal方法。此方法首先根据起始角和对应片元位置折算出的角度计算出一个正弦值,然后将此正弦值加上雾平面的高度作为扰动后的雾平面高度。然后计算从摄像机到待处理片元的射线对应的参数方程(摄像机位置+(待处理片元位置-摄像机位置)*t)与扰动后雾平面交点处的参数值(t值)。若t 值在 0~1 的范围内(表示待处理片元在雾平面以下),则根据待处理片元的位置到交点的距离计算雾浓度因子的大小。
片元着色器的 main 方法,其中首先执行了基础光照和过程纹理计算,根据片元高度计算出了待处理片元的纹理采样颜色值。之后雾化远处操作,最后计算出体积雾浓度因子,最后根据雾浓度因子、雾的颜色及片元本身的纹理采样颜色计算出片元的最终颜色值。
最后运行结果可见下图或者文章开头动图:
到这里为止,体积雾技术就介绍完了,经过上面的介绍你可能已经发现体积雾并不是实际存在的 3D 模型,只是在应该被雾覆盖的片元上通过某种计算模型的计算混合了雾的颜色,最后造成了有雾覆盖的效果。体积雾的实际计算模型有很多,本部分只给出了比较简单的一种(体积雾实现原理参照《OpenGL ES 3.X 游戏开发》实现,有兴趣的可购买书籍详看)。