之前在知乎上看到有大佬模拟了云海效果,正好之前项目里要用,就仔细研究一下,发现确实挺有意思的。
主要原理就是视差映射ParallaxMapping,先主要介绍一下视差映射的原理。
视差映射ParallaxMapping
说起视差映射,首先就要说起大家都不陌生的法线贴图技术。
- 法线贴图把法线储存在贴图的RGB通道中,在片元着色器里采样后,再计算光照,就可以在物体表面模拟凹凸的细节,让原本平滑、没什么细节的表面,可以模拟丰富细节的表面上的光照效果和反射效果等。
- 但是,在视线离物体很近的时候,法线贴图模拟出的凹凸效果往往就会不那么真实了。
- 如果配合上一张高度图,再加上视差映射技术,就可以让细节的真实感更进一步
原理
- 如上图所示,0.0的平面即为真实的模型平面,我们需要在其上模拟出起伏凹凸的高度,即下方的凹凸。
- 如果要模拟这样的凹凸,也就是正常视线看到的T0处,需要采样的得到,却是视线V通过T0的延长线交于所需模拟凹凸平面上的那一点在MainTex上的对应采样点信息
- 也就是需要求得每个点的采样uv,关于视角方向viewDir和该点高度信息height的一个偏差offset,让其对MainTex的采样满足模拟凹凸
- 那么,在法线贴图之外,还需要一张高度贴图,从0到1表示高度从最低到最高,只需要一个通道,所以可以写入其他贴图的不用的a通道
- 首先,使用viewDir.xy除以viewDir.z可以得到uv的所需偏移方向
- 注意:这里使用的viewDir是切线空间的视角方向,这样才能正好对应uv和垂直方向上的偏移
- 然后采样高度图,得到T0处的高度H(T0),H(T0) * viewDir.xy/viewDir.z即可得到uv的偏移offset
- 如果不除以视角方向viewDir的z分量,就叫带偏移上限的视差映射,而除以z分量,就是原始视差映射,原始视差映射在视角偏向掠射角时会产生错误的效果
- 这种方法性能很好,因为只用采样一次heightmap,就可以得出结果。但求得的采样点与实际交点偏差较大,因此效果一般
- 下面介绍三种可以求得较精确交点,也就是效果更好,但性能也相应下降的两种视差映射优化方法
陡峭视差映射(Steep Parallax Mapping,SPM)
- 如上图所示,把从0到1的高度平均分为若干层
- 在T0处采样高度,并逐次把当前层高度步进一个layerHeight(图中是0.125),把uv增加offset为layerHeight * (viewDir.xy/viewDir.z)
- 如果此次采样得到的高度值高于当前层的高度值,说明凹凸的平面依然在层之上,继续步进
- 如果此次采样得到的高度值低于当前层的高度值,说明凹凸的平面已经在当前层之下了,而上次的采样所得的结果还是凹凸平面在当前层之上,所以交点一定在上次采样点与此次采样点之间
- 最基础的方法就是一直循环到结束,输出当前uv作为最终使用的uv
- 三种优化方法中,这种方法性能最好,但效果最差
浮雕视差映射(Relief Parallax Mapping,RPM)
- 在SPM的基础上,更精准的寻找交点
- 也就是在最后两次的采样结果之上,使用二分法,依次逼近实际交点
- 即在T3时停止循环,并向T2步进层高的一半高度,并采样得到当前层高度与高度图采样高度的关系
- 如果层高度大于高度图高度,说明该点在T3和T2的中点与T3之间,就继续向T2步进一半的一半高度,
- 如果层高度小于高度图高度,说明该点在T3和T2的中点与T2之间,就返回T3处,再向T2步进一半的一半高度
- 周而复始,依次循环,直到层高度等于高度图高度,或者达到设置的循环最多次停止
- 三种优化方法中,这种方法效果最好,但由于循环次数过多,性能最差
视差遮蔽映射(Parallax Occlusion Mapping,POM)
- 这种方法是基于SPM的基础上的另一个优化版本
- 如图所示,POM只是对最后两次的采样结果进行简单的插值计算,没有像RPM一样进行二分搜索
- nextHeight = H(T3)- currentLayerHeight
- prevHeight = H(T2)-(currentLayerHeight - layerHeight)
- weight = nextHeight/(nextHeight - prevHeight)
- Tp = T(T2)weight + T(T3)(1.0 - weight)
- POM会比RPM更容易漏掉一些小细节,在短距离内发生高度的大幅度变化的情况,使用POM也会得到错误的结果
- 三种优化方案中,这种方法效果适中,性能也较为优良
- 所以最后选中POM进行云海效果模拟的方法
- POM代码
float3 ParallaxMapping(in float3 viewDir, in float2 texcoord, in float height)
{
viewDir.z = abs(viewDir.z) + 0.42;
const float numLayers = 10;
float layerHeight = height/numLayers;
float3 offsetStep = layerHeight * viewDir/viewDir.z;
offsetStep.z /= height;
//xy记录当前uv,z记录当前LayerHeight
float3 curTexcoord = float3(texcoord, 0);
float3 prevTexcoord = curTexcoord;
float curTexHeight = tex2D(_CloudTex, curTexcoord).a;
float prevTexHeight = curTexHeight;
//当前层高度高于高度图高度时停止循环
while(curTexHeight > curTexcoord.z)
{
prevTexcoord = curTexcoord;
curTexcoord += offsetStep;
prevTexHeight = curTexHeight;
curTexHeight = tex2Dlod(_CloudTex, float4(curTexcoord.xy,0,0)).a;
}
//当高度为0的时候,直接不会进入循环,导致分母为0
//所以要加一个极小值让分母不为0
float w = (curTexHeight - curTexcoord.z)/(abs((curTexHeight - curTexcoord.z) - (prevTexHeight - prevTexcoord.z))+1e-7f);
curTexcoord = curTexcoord * (1-w) + prevTexcoord * w;
curTexcoord.z = curTexHeight * (1-w) + prevTexHeight * w;
//输出一个float3类型的变量,xy为偏差后的uv,z为高度
return curTexcoord;
}
模拟自阴影
- 自阴影,即为模型自身的一部分阻挡住了光线射向另一部分,导致另一部分产生阴影的现象
- 而一个平面显然是不会有自阴影,但现在使用视差映射在平面表面模拟了凹凸,那么自阴影现象也是需要存在的
- 同样可以使用视差映射接近的算法,确定一个点是否在阴影中
- 首先使用刚刚视差映射得到的最终uv和最终高度h,依次向光源方向步进
- 如果层高度小于采样点高度,就说明该点在表面之下,光线被阻挡,如果是计算硬阴影,直接设置为阴影;如果是计算软阴影,增加阴影系数,继续步进
- 如果层高度大于采样点高度,就说明该点在表面之上,光线没有被阻挡
- 软阴影需要计算从起始点到最终不阻挡光线的那个点,而阴影系数根据当前层深度和当前高度图深度之间的差异计算,计算软阴影系数的公式如下
- 代码如下
float ParallaxSoftShadow(in float3 lightDir, in float2 texcoord, in float height)
{
float shadowMultiplier = 1;
const float minLayers = 25;
const float maxLayers = 50;
lightDir.z = abs(lightDir.z) + 0.42;
if(dot(float3(0,0,1), lightDir) > 0)
{
float numSamplesUnderSurface = 0;
shadowMultiplier = 0;
//光线靠近垂直方向时,减少分层,光线偏离垂直方向时(更为倾斜),增加分层,在保证效果的前提下节约性能
float numLayers = lerp(maxLayers, minLayers, abs(dot(float3(0,0,1), lightDir)));
float layerHeight = height/numLayers;
half2 offsetStep = lightDir.xy/lightDir.z/numLayers;
float curLayerHeight = height - layerHeight;
float2 curTexcoord = texcoord + offsetStep;
float heightFromTexture = tex2D(_CloudTex, curTexcoord).a;
int stepIndex = 1;
while(curLayerHeight > 0)
{
if(heightFromTexture < curLayerHeight)
{
numSamplesUnderSurface +=1;
shadowMultiplier = max(shadowMultiplier, (curLayerHeight - heightFromTexture)*(1.0 - stepIndex/numLayers));
}
stepIndex += 1;
curLayerHeight -= layerHeight;
curTexcoord += offsetStep;
heightFromTexture = tex2Dlod(_CloudTex, float4(curTexcoord, 0,0)).a;
}
shadowMultiplier = numSamplesUnderSurface < 1 ? 1 : 1-shadowMultiplier;
}
return shadowMultiplier;
}
模拟云海效果,只用高度图即可,把高度图写入主图的a通道,使用视差映射的算法,得到偏差后的坐标,采样MainTex,最后再乘上阴影系数即可。
完整代码如下
Shader "Custom/Scene/Cloud"
{
Properties
{
_CloudTex ("Cloud Texture", 2D) = "white"{}
_CloudColor ("Cloud Color", Color) = (1, 1, 1, 1)
_CloudSpeed ("Cloud Speed", Vector) = (2, 1, 0, 0)
_Height ("Height", Range(0, 1)) = 0.5
}
SubShader
{
Tags { "Queue"="Transparent" "RenderType"="Opaque" }
Pass
{
Blend SrcAlpha OneMinusSrcAlpha
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "Lighting.cginc"
sampler2D _CloudTex;
float4 _CloudTex_ST;
fixed4 _CloudColor;
float4 _CloudSpeed;
float _Height;
struct v2f
{
float4 vertex : SV_POSITION;
float2 uv : TEXCOORD0;
float3 tanViewDir : TEXCOORD1;
float3 tanLightDir : TEXCOORD2;
};
v2f vert (appdata_tan v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.texcoord, _CloudTex) + frac(_Time.x * _CloudSpeed.xy);
TANGENT_SPACE_ROTATION;
o.tanViewDir = mul(rotation, ObjSpaceViewDir(v.vertex));
o.tanLightDir = mul(rotation, ObjSpaceLightDir(v.vertex));
return o;
}
float3 ParallaxMapping(in float3 viewDir, in float2 texcoord, in float height)
{
viewDir.z = abs(viewDir.z) + 0.42;
const float numLayers = 10;
float layerHeight = height/numLayers;
float3 offsetStep = layerHeight * viewDir/viewDir.z;
offsetStep.z /= height;
//xy记录当前uv,z记录当前LayerHeight
float3 curTexcoord = float3(texcoord, 0);
float3 prevTexcoord = curTexcoord;
float curTexHeight = tex2D(_CloudTex, curTexcoord).a;
float prevTexHeight = curTexHeight;
//当前层高度高于高度图高度时停止循环
while(curTexHeight > curTexcoord.z)
{
prevTexcoord = curTexcoord;
curTexcoord += offsetStep;
prevTexHeight = curTexHeight;
curTexHeight = tex2Dlod(_CloudTex, float4(curTexcoord.xy,0,0)).a;
}
float w = (curTexHeight - curTexcoord.z)/(abs((curTexHeight - curTexcoord.z) - (prevTexHeight - prevTexcoord.z))+1e-7f);
curTexcoord = curTexcoord * (1-w) + prevTexcoord * w;
curTexcoord.z = curTexHeight * (1-w) + prevTexHeight * w;
return curTexcoord;
}
float ParallaxSoftShadow(in float3 lightDir, in float2 texcoord, in float height)
{
float shadowMultiplier = 1;
const float minLayers = 25;
const float maxLayers = 50;
lightDir.z = abs(lightDir.z) + 0.42;
if(dot(float3(0,0,1), lightDir) > 0)
{
float numSamplesUnderSurface = 0;
shadowMultiplier = 0;
//光线靠近垂直方向时,减少分层,光线偏离垂直方向时(更为倾斜),增加分层,在保证效果的前提下节约性能
float numLayers = lerp(maxLayers, minLayers, abs(dot(float3(0,0,1), lightDir)));
float layerHeight = height/numLayers;
half2 offsetStep = lightDir.xy/lightDir.z/numLayers;
float curLayerHeight = height - layerHeight;
float2 curTexcoord = texcoord + offsetStep;
float heightFromTexture = tex2D(_CloudTex, curTexcoord).a;
int stepIndex = 1;
while(curLayerHeight > 0)
{
if(heightFromTexture < curLayerHeight)
{
numSamplesUnderSurface +=1;
shadowMultiplier = max(shadowMultiplier, (curLayerHeight - heightFromTexture)*(1.0 - stepIndex/numLayers));
}
stepIndex += 1;
curLayerHeight -= layerHeight;
curTexcoord += offsetStep;
heightFromTexture = tex2Dlod(_CloudTex, float4(curTexcoord, 0,0)).a;
}
shadowMultiplier = numSamplesUnderSurface < 1 ? 1 : 1-shadowMultiplier;
}
return shadowMultiplier;
}
fixed4 frag (v2f i) : SV_Target
{
float3 uv = ParallaxMapping(normalize(i.tanViewDir), i.uv, _Height);
float shadowMultiplier = ParallaxSoftShadow(normalize(i.tanLightDir), uv.xy, uv.z);
half4 c = tex2D(_CloudTex, uv.xy) * _CloudColor;
c.rgb *= _LightColor0.rgb * (shadowMultiplier);
return c;
}
ENDCG
}
}
}