基于ShadowMap的场景静态阴影

基于ShadowMap的场景静态阴影

前言:手游中多数情况,场景的灯光信息会由场景美术预先通过烘焙的方式生成好,这样虽然带来了性能上的高效,但是无法达到一些表现的需求,例如通过lightmapmap提前烘焙好的场景阴影,当角色步入阴影当中的时候无法达到一个逐渐进入的效果,入下图

(图1)烘焙好的场景阴影贴图
(图2)预期的效果

有了需求,接下来就是制定解决方案,同时还要考虑移动端性能限制。Shadowmap是现在主流的阴影解决方案,包括Unity的阴影也是基于此,但是我们项目中不需要对动态物体采用这种方式产生阴影,只需要对静态的场景物体,即设置为static的产生这种阴影区域,所以最终我们采取在进入场景前,提前缓存好一张shadowmap,由动态物体接收这个阴影来达到图2的效果。

  • ShadowMap原理
  1. 从产生阴影的光源(在通常项目中为Directional Light)放置一个相机,渲染一次场景,获取深度贴图。
  2. 场景内物体渲染时,将自身世界坐标转入刚才的阴影相机的投影坐标得到统一坐标系下的深度值然后和对应深度贴图中保存的深度值比较,(在d3d11下)如果小于保存的深度值代表此像素处于阴影。
  • 获取ShadowMap

有了原理,我们接下来第一步就是要保存场景的ShadowMap。首先是建立一个Camera用来生成ShadowMap,这个camera必须和场景的主光源,通常为Directional Light,有一样的Forward朝向(Directional Light的话,需开启正交相机)。Camera的视窗决定了那些区域能产生阴影,通常有两种方案来决定视窗范围:

(1)FitScene

顾名思义,阴影摄像机视锥覆盖到场景内所有角色可行走范围即可,区域外由于角色走不到,不会产生阴影交互,生成也是浪费。比较适合小场景。对于只计算静态阴影,只需生成一次即可。缺点是当场景较大的时候,摄像机视锥很大导致ShadowMap纹理很大或者分辨率较低。

(2)FitView

阴影摄像机视锥覆盖观察摄像机视锥,即阴影相机只渲染主观察相机能看到的地方,相对FitScene,FitView的阴影区域生成利用率更高,因为处于可行走区域但是不被主观察相机渲染的地方是不会被生成到ShadowMap中的,但是主相机的参数只要一改变,对应阴影摄像机的视锥参数也会改变,所以需要实时调整阴影摄像机的视锥以匹配观察摄像机的视锥。

由于项目和移动端性能考虑,我们项目采用了FitScene的方式来生成ShadowMap。在场景搭建好之后,美术或者策划只要在场景内适当位置摆放好一个用来生成的ShadowMap的摄像机即可,我们这里为其编写了一些工具,可简化美术或者策划端的工作。

只要提供行走区域和主光源即可算出FitScene的包围盒

有了正确的阴影摄像机参数设置,我们就可以在游戏开始前对场景生成ShadowMap。这里我们采用Camera自带的方法RenderWithShader,渲染一次到rendertexture,然后即可关闭这个相机。下面给出ShadowMapCapture(阴影捕获Shader)主体。

v2f vert (appdata v)
{
	v2f o;
	o.vertex = UnityObjectToClipPos(v.vertex);
	o.depth = o.vertex.zw;
	return o;
}
			
fixed4 frag (v2f i) : SV_Target
{
	float depth = i.depth.x / i.depth.y;
	return EncodeFloatRGBA((depth * 0.5 + 0.5));
}

通过将float depth = i.depth.x / i.depth.y我们可以获取到某个像素在阴影摄像机裁切空间的深度,然后通过UnityCginc中提供的EncodeFloatRGBA方法将一个Float值存储在float4值当中,在frag函数中这个float4被当做颜色最终输出。这里要注意的一点是裁剪空间里OpenGL的z是[-1, 1],而D3D是[0, 1],而EncodeFloatRGBA方法只接受[0..1)范围的输入值,所以这里我们还得考虑手机平台的OpenGL,将depth值进行一次映射。实际效果如下图。

选中阴影摄像机的预览图
通过shader获取的ShadowMap纹理
  • 应用ShadowMap

有了ShadowMap纹理之后,我们需要做的是如何让我们的角色shader支持。上面这张ShadowMap纹理中保存的是在阴影摄像机裁切空间坐标系下的Z深度,所以要进行比较,我们角色需要将自身像素的坐标转换到该坐标系下比较才有意义,好在Unity提供了很方便的坐标系转换API。下面给出C#里如何获取坐标系转换的代码。

public void SetGlobalShaderParam()
{
    //世界->阴影摄像机观察空间
    Matrix4x4 worldToView = LightCamera.worldToCameraMatrix;
    //阴影摄像机观察空间->阴影摄像机裁切空间
    Matrix4x4 projection = GL.GetGPUProjectionMatrix(LightCamera.projectionMatrix, false);
    //将裁切空间的XY坐标系[-1, 1]映射到uv坐标[0, 1]
    Matrix4x4 posToUV = new Matrix4x4();
    posToUV.SetRow(0, new Vector4(0.5f, 0, 0, 0.5f));
    posToUV.SetRow(1, new Vector4(0, 0.5f, 0, 0.5f));
    posToUV.SetRow(2, new Vector4(0, 0, 1, 0));
    posToUV.SetRow(3, new Vector4(0, 0, 0, 1));
    //最终世界坐标系到ShadowMao纹理UV坐标系的转换矩阵
    LightProjectMatrix = posToUV * projection * worldToView;
    Shader.SetGlobalMatrix("_LightProjection", LightProjectMatrix);
}

在Shader中获取到到转换坐标系后,就可以在frag函数中将像素的worldPos转换成到ShadowMap纹理UV并采样到该像素对应在ShadowMap中的深度值。然后纹理中存储的深度和该像素的深度做比较可以得出该像素是否处于阴影中,然后自己做相应的修改,这里要注意的是在d3d和移动端OpenGl的坐标系不同,会导致Z值判断不同,用UnityCG.cginc中定义的UNITY_REVERSED_Z来判断即可。下面给出ShadowMapReceiver(阴影接受Shader)中Frag中相关代码

//世界坐标->阴影摄像机裁切空间坐标
fixed4 lightClipPos = mul(_LightProjection, i.worldPos);
//shadowmap 纹理采样
fixed4 depthRGBA = tex2D(_LightDepthTex, lightClipPos.xy);
//还原深度值
float depth = DecodeFloatRGBA(depthRGBA);

lightClipPos.z = lightClipPos.z / lightClipPos.w;
//截取shadowmap的时候对depth做了范围映射,这样采取同样处理
float pixeldepth = (lightClipPos.z * 0.5 + 0.5);
//opengl d3d Z轴正负需要考虑
#if defined(UNITY_REVERSED_Z)
if (pixeldepth < depth)
{
	finalColor.rgb *= _ShadowColorAtten;
}
#else
if (pixeldepth  > depth)
{
	finalColor.rgb *= _ShadowColorAtten;
}
#endif		

最终,对于场景内静态区域的阴影,我们可以得到如下图的效果。

场景结构,LightMap已经提前烘焙好
我们的主角球已经可以接受到正确的场景阴影信息
  • 改进

很快美术提来了新的需求,我们的场景有一片树林,但是背景的树是用贴图的方式制作的,这在截取ShadowMap纹理的时候,这片树林被当做一面墙来产生阴影了,效果如图。

plane上的阴影是烘焙产生的,但是小球接受的阴影却是错误的

导致这个结果的原因是我们在用阴影摄像机RenderWithShader的时候没有对CutOut和Blend两种透明方式做处理,这里处理的方式其实也很简单。

public void RenderWithShader(Shader shader, string replacementTag)

replacementTag这个参数用来指定使用具有该标签的shader替换,比如场景里A物体的tag包含{"RenderType"="Opaque"},那么就会从传入的shader(ShadowMapCapture)中查找具有相同tag的subshader去替换渲染。那么对应非透明(Opaque),透明裁切(TransparentCutout), 透明混合(Transparent)这三个主要的标签,我们要分别写三个subshader。其中Opaque上文已经给出,对于TransparentCutout、Transparent这两种其实处理方法一致,统一采用alpha cutout的方式,这样的相邻阴影会衔接在一起,无明显过度效果,但是处理起来方便简单。下面给出代码

float depth = i.depth.x / i.depth.y;
fixed4 color = tex2D(_MainTex, i.uv);
//这里裁切透明度可以自己设置
if (color.a < 0.3)
{
	return fixed4(0, 0, 0, 0);
}
else
{
	return EncodeFloatRGBA((depth * 0.5 + 0.5));
}

同时对于这些SubShader,我们要修改如下功能

//写入深度并且在混合时取最大保证透明物体的层叠问题
BlendOp Max
ZWrite On
//避免被实际深度测试剔除
ZTest Off
//渲染背面,避免一些片面的背部朝向光源
Cull off

最终效果,这里我把球换成了Cube,这样效果比较明显一点

  • 结语

以上是我结合网上的ShadowMap资料,以移动端为目标平台所制作出的一个静态阴影解决方案。

它的优点是:

  1. 性能开销相对开启实时灯光接收阴影小
  2. 能够实现静态物体的阴影区域进入效果

缺点也是有的:

  1. 无法实现移动物体的自身阴影,因为ShadowMap只在最开始的时候截取了一张,要想实现动态阴影,必须同步一直更新ShadowMap,性能开销又变大了,不过对于角色自身的阴影,我们也有其他的投机的解决方案
  2. 对于FitScene来获取ShadowMap纹理,对于大场景不适用,要么纹理大,精度高,要么压缩纹理导致阴影精度低,产生很糟糕的锯齿感。而改用FitView同样要面临同步更新ShadowMap纹理的问题。

最后,实际的效果需要结合项目自身决定,所幸我们项目正好满足上述各种限制:较小场景,同时动态物体阴影采取其他方案解决。


最后附上参考链接:

编辑于 2018-06-20 17:51