Unity平面阴影(王者荣耀阴影实现)

Unity平面阴影(王者荣耀阴影实现)

前言

上篇文章写了使用投影器实现阴影,最近几天业余时间学习了王者荣耀的阴影实现,通过使用Adreno Profiler工具分析,发现王者荣耀的阴影实现只是简单的平面阴影,不过王者为了更好的阴影效果,通过模版使阴影有了一个淡化效果(如上图所示)。通过分析王者荣耀阴影实现,(1)可以学习一下平面阴影的数学推导,(2)可以学习一下模版的使用,以后可以更灵活的使用模版。下面是这个Demo的视频。

https://www.zhihu.com/video/1016815623216791552

工具准备

分析其他手游的渲染实现,需要专业的分析工具,分析王者荣耀阴影的时候,我使用的是Adreno Profiler, 这个工具有几个优点,(1)可以看到glsl代码,相对于有些工具只能抓取到汇编代码,glsl还是有相当好的可读性 (2)可以实时的修改glsl代码,也可以实时的修改渲染状态来调试渲染效果,从而可以更方便的分析出其他手游渲染技术的实现。

关于Adreno Profiler的使用,我主要学习的是下面这篇文章。通过这篇教程的学习,可以很快掌握使用Adreno Profiler调试其他手机游戏了。

http://qiankanglai.me/2015/05/16/Adreno-Profiler/qiankanglai.me

分析

抓取了一帧王者,如下图所示。

找到阴影渲染的那一帧,分析了实现阴影的glsl代码和渲染状态,以及显卡寄存器数据。

  • 阴影的glsl的vertshader和pixelshader如下所示
  • 顶点shader,如下图所示,顶点shader主要是做了平面坐标的计算。
attribute vec4 _glesVertex;
uniform highp mat4 _Object2World;
uniform highp mat4 unity_MatrixVP;
uniform highp vec4 _ShadowPlane;
uniform highp vec4 _ShadowProjDir;
uniform highp vec4 _WorldPos;
varying highp vec3 xlv_TEXCOORD0;
varying highp vec3 xlv_TEXCOORD1;
void main ()
{
  highp vec4 tmpvar_1;
  tmpvar_1 = normalize(_ShadowProjDir);
  highp vec3 tmpvar_2;
  tmpvar_2 = (_Object2World * _glesVertex).xyz;
  highp vec3 tmpvar_3;
  tmpvar_3 = (tmpvar_2 - ((
    (dot (_ShadowPlane.xyz, tmpvar_2) - _ShadowPlane.w)
   / 
    dot (_ShadowPlane.xyz, tmpvar_1.xyz)
  ) * tmpvar_1.xyz));
  highp vec4 tmpvar_4;
  tmpvar_4.w = 1.0;
  tmpvar_4.xyz = tmpvar_3;
  gl_Position = (unity_MatrixVP * tmpvar_4);
  xlv_TEXCOORD0 = _WorldPos.xyz;
  xlv_TEXCOORD1 = tmpvar_3;
}
  • 像素shader, 像素shader是为了实现阴影从人物中心向周围的淡化效果。
precision highp float;
uniform mediump float _ShadowInvLen;
uniform mediump vec4 _ShadowFadeParams;
varying highp vec3 xlv_TEXCOORD0;
varying highp vec3 xlv_TEXCOORD1;
void main ()
{
  lowp vec4 tmpvar_1;
  mediump vec3 posToPlane_2;
  highp vec3 tmpvar_3;
  tmpvar_3 = (xlv_TEXCOORD0 - xlv_TEXCOORD1);
  posToPlane_2 = tmpvar_3;
  mediump vec4 tmpvar_4;
  tmpvar_4.xyz = vec3(0.0, 0.0, 0.0);
  tmpvar_4.w = (pow ((1.0 - 
    clamp (((sqrt(
      dot (posToPlane_2, posToPlane_2)
    ) * _ShadowInvLen) - _ShadowFadeParams.x), 0.0, 1.0)
  ), _ShadowFadeParams.y) * _ShadowFadeParams.z);
  tmpvar_1 = tmpvar_4;
  gl_FragData[0] = tmpvar_1;
}
  • 几个需要用到的寄存器状态的数值

ShadowFaceParams是淡化参数

ShadowInvLen 字面意思,阴影长度

ShadowPlane 阴影平面,用一个Vector4就是表示平面

ShadowProjDir 这个也就是灯光方向

WorldPos 人物的当前坐标

ShadowFaceParams	(0, 1.5, 0.7, 0)
ShadowInvLen 	0.4449261
ShadowPlane 	(0, 1, 0, 0.1)
ShadowProjDir 	(-0.06323785, -0.9545552, -0.2912483, 1)
WorldPos 	(?, 0.05, ?, 1)
  • 渲染阴影时候的状态

第一张图是AlphaBlend状态, 在Unity中其实就是如下参数, 实现一个默认的Alpha混合。

Blend SrcAlpha OneMinusSrcAlpha

第二张图是裁剪和深度状态,裁剪是背面裁剪,深度是开启了深度测试,我觉得阴影不需要写入深度。在unity中可以描述为下面的状态

Cull Back
ZWrite Off

第三张图显示的是本次drawcall几个通道是可以使用的,如下图所以,可以使用RGB通道,Alpha通道不能使用,Unity中的状态如下所示

ColorMask RGB

第四张图显示模版的状态,看状态开的是双面模版,只看一个面的情况,模版的判断函数是

Equal, 模版通过深度测试的时候使用的是Invert参数, 在unity中还原出模版所有的状态

Stencil
{
	Ref 0			
	Comp Equal			
	WriteMask 255		
	ReadMask 255
	//Pass IncrSat
	Pass Invert
	Fail Keep
	ZFail Keep
}

到这里为止渲染状态,shader代码,寄存器数值都知道了,好了,万事俱备,开始unity的还原吧。

Unity实现

首先看下面的图。对于角色所有的Skinned Mesh Renderer,都使用了自定义shader,平面阴影主要也是阴影shader的实现

下面看看阴影的shader, shader分为了两个Pass, 分布列出

首先看下设置的Queue, 这里我Queue是Geometry+10, 在所有Geometry渲染完以后渲染。主要是考虑到Pass2渲染阴影的时候使用了AlphaBlend, 想在所有的Geometry渲染完以后再AlphaBlend, 得出正确的渲染效果

Tags{ "RenderType" = "Opaque" "Queue" = "Geometry+10" }

看看Pass1, 这里的Pass1是unity生成的默认shader, 也就是角色的渲染,每个项目都有自己单独的一套渲染实现,和绘制阴影没有关系

Pass
		{
			CGPROGRAM

			#pragma vertex vert
			#pragma fragment frag
			// make fog work
			#pragma multi_compile_fog
			
			#include "UnityCG.cginc"
			
			struct appdata
			{
				float4 vertex : POSITION;
				float2 uv : TEXCOORD0;
			};

			struct v2f
			{
				float2 uv : TEXCOORD0;
				UNITY_FOG_COORDS(1)
				float4 vertex : SV_POSITION;
			};

			sampler2D _MainTex;
			float4 _MainTex_ST;

			v2f vert (appdata v)
			{
				v2f o;
				o.vertex = UnityObjectToClipPos(v.vertex);
				o.uv = TRANSFORM_TEX(v.uv, _MainTex);
				UNITY_TRANSFER_FOG(o,o.vertex);
				return o;
			}
			
			fixed4 frag (v2f i) : SV_Target
			{
				// sample the texture
				fixed4 col = tex2D(_MainTex, i.uv);
				// apply fog
				UNITY_APPLY_FOG(i.fogCoord, col);
				return col;
			}
			
			ENDCG
		}

主要看看Pass2, 通过对glsl代码翻译,vert计算出灯光方向上的平面坐标,frag通过一个公式对阴影做了淡化。

Pass
		{		
			Blend SrcAlpha  OneMinusSrcAlpha
			ZWrite Off
			Cull Back
			ColorMask RGB
			
			CGPROGRAM
			
			#pragma vertex vert
			#pragma fragment frag

			float4 _ShadowPlane;
			float4 _ShadowProjDir;
			float4 _WorldPos;
			float _ShadowInvLen;
			float4 _ShadowFadeParams;
			
			struct appdata
			{
				float4 vertex : POSITION;
			};

			struct v2f
			{
				float4 vertex : SV_POSITION;
				float3 xlv_TEXCOORD0 : TEXCOORD0;
				float3 xlv_TEXCOORD1 : TEXCOORD1;
			};

			v2f vert(appdata v)
			{
				v2f o;

				float3 tmpvar_1 = normalize(_ShadowProjDir);
				float3 tmpvar_2 = mul(unity_ObjectToWorld, v.vertex).xyz;
				float3 tmpvar_3 = (tmpvar_2 - (((dot(_ShadowPlane.xyz, tmpvar_2) - _ShadowPlane.w) / dot(_ShadowPlane.xyz, tmpvar_1.xyz)) * tmpvar_1.xyz));
				o.vertex = mul(unity_MatrixVP, float4(tmpvar_3, 1.0));
				o.xlv_TEXCOORD0 = _WorldPos.xyz;
				o.xlv_TEXCOORD1 = tmpvar_3;

				return o;
			}

			float4 frag(v2f i) : SV_Target
			{
				float3 posToPlane_2 = (i.xlv_TEXCOORD0 - i.xlv_TEXCOORD1);
				float4 color;
				color.xyz = float3(0.0, 0.0, 0.0);
				color.w = (pow((1.0 - clamp(((sqrt(dot(posToPlane_2, posToPlane_2)) * _ShadowInvLen) - _ShadowFadeParams.x), 0.0, 1.0)), _ShadowFadeParams.y) * _ShadowFadeParams.z);

				return color;
			}
			
			ENDCG
		}

好了,shader好了,那就看一下运行时候的效果。

平面阴影正常显示,也有淡化效果,但是阴影有重叠。这个不是我们想要的效果。

怎么办,上面已经分析过王者手游的渲染状态,通过调试Adreno Profiler中阴影的状态,发现关闭模版的情况下,王者的阴影也会出现这种错误的渲染,王者是通过模版来消除阴影重叠的。

下图的王者阴影使用模版的情况下模版buffer的状态。可以清晰的看到模版写入了值。通过使用模版,对于需要做淡化美观的阴影,消除了阴影重叠的现象

那好,在Unity的Pass2中加入模版状态吧。

			Stencil
			{
				Ref 0			
				Comp Equal			
				WriteMask 255		
				ReadMask 255
				//Pass IncrSat
				Pass Invert
				Fail Keep
				ZFail Keep
			}

这里是模版参数的具体解释。

Stencil
{
    Ref referenceValue //参考值
    ReadMask  readMask  //读取掩码,取值范围也是0-255的整数,默认值为255,二进制位11111111,即读取的时候不对referenceValue和stencilBufferValue产生效果,读取的还是原始值
    WriteMask writeMask  //输出掩码,当写入模板缓冲时进行掩码操作(按位与【&】),writeMask取值范围是0-255的整数,默认值也是255,即当修改stencilBufferValue值时,写入的仍然是原始值
    Comp comparisonFunction  //条件,关键字有,Greater(>),GEqual(>=),Less(<),LEqual(<=),Equal(=),NotEqual(!=),Always(总是满足),Never(总是不满足)
    Pass stencilOperation  //条件满足后的处理
    Fail stencilOperation  //条件不满足后的处理
    ZFail stencilOperation  //深度测试失败后的处理
}
上面的意思就是当像素通过了深度测试,背面裁剪,可以渲染的情况下,如果像素的模版值等于0,则写入模版值,这里写入的模版值是用的Invert状态,也就是255, 当然也可以用IncrSat状态,这样写入的模版值就为1
可以想象一下,当一个像素所在的位置已经模版值不为0的情况下,另外一个像素再想写入这个像素的位置,它是通过不了模版测试的。
通过模版测试的状态设置,就可以得到正确的阴影效果。

这个是王者平面阴影的亮点所在了。一般的平面阴影在ps输出颜色的时候,只会单纯的输出纯色,或者没有Alpha混合状态,王者为了阴影有淡化效果,使用了模版。下面是最后正确的效果截图。

项目的github地址

xieliujian/UnityDemo_PlanarShadowgithub.com图标

参考文章

http://qiankanglai.me/2016/12/23/planar-shadow/qiankanglai.me

可以看到,这边的平面阴影是纯色的影子,对比王者的阴影效果。王者的阴影效果要好一些。

还没有结束

上面的参考文章链接有一个平面阴影的计算公式的维基百科, 里面有平面阴影的推导。

Line-plane intersectionen.wikipedia.org

数学不好,自己试着也推导了平面阴影。(感觉这个挺重要的。)

试着重新修改一下Pass2的vert的计算公式, 这样就清晰多了。

	float3 lightdir = normalize(_ShadowProjDir);
	float3 worldpos = mul(unity_ObjectToWorld, v.vertex).xyz;
	// _ShadowPlane.w = p0 * n  // 平面的w分量就是p0 * n
	float distance = (_ShadowPlane.w - dot(_ShadowPlane.xyz, worldpos)) / dot(_ShadowPlane.xyz, lightdir.xyz);
	worldpos = worldpos + distance * lightdir.xyz;
	o.vertex = mul(unity_MatrixVP, float4(worldpos, 1.0));
	o.xlv_TEXCOORD0 = _WorldPos.xyz;
	o.xlv_TEXCOORD1 = worldpos;


2018.8.30补充

知乎专栏上关注了这个技术美术 @喵喵Mya的专栏,看到了她分享的文章。里面也是介绍了这个平面阴影的做法

喵喵Mya:使用顶点投射的方法制作实时阴影zhuanlan.zhihu.com图标

看了她的shader阴影衰减公式,添加到目前的shader中。

// 下面两种阴影衰减公式都可以使用(当然也可以自己写衰减公式)
// 1. 王者荣耀的衰减公式
color.w = (pow((1.0 - clamp(((sqrt(dot(posToPlane_2, posToPlane_2)) * _ShadowInvLen) - _ShadowFadeParams.x), 0.0, 1.0)), _ShadowFadeParams.y) * _ShadowFadeParams.z);

// 2. https://zhuanlan.zhihu.com/p/31504088 这篇文章介绍的另外的阴影衰减公式
//color.w = 1.0 - saturate(distance(i.xlv_TEXCOORD0, i.xlv_TEXCOORD1) * _ShadowFalloff);

编辑于 2018-08-30

文章被以下专栏收录