之前在知乎上看到有大佬模拟了云海效果,正好之前项目里要用,就仔细研究一下,发现确实挺有意思的。

unity海水动态材质_图形学


主要原理就是视差映射ParallaxMapping,先主要介绍一下视差映射的原理。

视差映射ParallaxMapping

说起视差映射,首先就要说起大家都不陌生的法线贴图技术。

  • 法线贴图把法线储存在贴图的RGB通道中,在片元着色器里采样后,再计算光照,就可以在物体表面模拟凹凸的细节,让原本平滑、没什么细节的表面,可以模拟丰富细节的表面上的光照效果和反射效果等。
  • unity海水动态材质_游戏开发_02

  • 但是,在视线离物体很近的时候,法线贴图模拟出的凹凸效果往往就会不那么真实了。
  • unity海水动态材质_shader_03

  • 如果配合上一张高度图,再加上视差映射技术,就可以让细节的真实感更进一步
  • unity海水动态材质_图形学_04

原理

unity海水动态材质_图形学_05

  • 如上图所示,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)

unity海水动态材质_游戏开发_06

  • 如上图所示,把从0到1的高度平均分为若干层
  • 在T0处采样高度,并逐次把当前层高度步进一个layerHeight(图中是0.125),把uv增加offset为layerHeight * (viewDir.xy/viewDir.z)
  • 如果此次采样得到的高度值高于当前层的高度值,说明凹凸的平面依然在层之上,继续步进
  • 如果此次采样得到的高度值低于当前层的高度值,说明凹凸的平面已经在当前层之下了,而上次的采样所得的结果还是凹凸平面在当前层之上,所以交点一定在上次采样点与此次采样点之间
  • 最基础的方法就是一直循环到结束,输出当前uv作为最终使用的uv
  • 三种优化方法中,这种方法性能最好,但效果最差

浮雕视差映射(Relief Parallax Mapping,RPM)

unity海水动态材质_unity3d_07

  • 在SPM的基础上,更精准的寻找交点
  • 也就是在最后两次的采样结果之上,使用二分法,依次逼近实际交点
  • 即在T3时停止循环,并向T2步进层高的一半高度,并采样得到当前层高度与高度图采样高度的关系
  • 如果层高度大于高度图高度,说明该点在T3和T2的中点与T3之间,就继续向T2步进一半的一半高度,
  • 如果层高度小于高度图高度,说明该点在T3和T2的中点与T2之间,就返回T3处,再向T2步进一半的一半高度
  • 周而复始,依次循环,直到层高度等于高度图高度,或者达到设置的循环最多次停止
  • 三种优化方法中,这种方法效果最好,但由于循环次数过多,性能最差

视差遮蔽映射(Parallax Occlusion Mapping,POM)

unity海水动态材质_unity海水动态材质_08

  • 这种方法是基于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;
}

模拟自阴影

  • 自阴影,即为模型自身的一部分阻挡住了光线射向另一部分,导致另一部分产生阴影的现象
  • 而一个平面显然是不会有自阴影,但现在使用视差映射在平面表面模拟了凹凸,那么自阴影现象也是需要存在的
  • 同样可以使用视差映射接近的算法,确定一个点是否在阴影中
  • unity海水动态材质_unity海水动态材质_09

  • 首先使用刚刚视差映射得到的最终uv和最终高度h,依次向光源方向步进
  • 如果层高度小于采样点高度,就说明该点在表面之下,光线被阻挡,如果是计算硬阴影,直接设置为阴影;如果是计算软阴影,增加阴影系数,继续步进
  • 如果层高度大于采样点高度,就说明该点在表面之上,光线没有被阻挡
  • 软阴影需要计算从起始点到最终不阻挡光线的那个点,而阴影系数根据当前层深度和当前高度图深度之间的差异计算,计算软阴影系数的公式如下
  • unity海水动态材质_unity3d_10

  • 代码如下
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
        }
    }
}