游戏中为了表现更多的细节,实时阴影可能是我们开发过程中常见的需求。随着现在手游质量的越来越高, 很多游戏中也都把阴影作为一个比较大的亮点来展示。比如两年前上线的<功夫熊猫>, 大规模的为全屏角色采用动态阴影,在当时看起来效果还是很赞的。
当然根据不同类型的游戏类型不同, 在Unity上采用的实时阴影的方法也各有不同, 常用的大致有如下几种。

  • LightMap : 优点:对于一些静态的场景这个方案可能是性价比最高的一个方案, 缺点:只能是静态烘培,对一些大的场景LightMap体贴占内存可能会很大。
  • Projector :优点:可以很自由的控制, 缺点:绘制影子的对象无法Batch
  • ShadowMap:优点:获取深度图之后可以对更大范围的物体进行投影 缺点:实现代码量较多
    那回到我们如上标题的目标诉求上来说,如果想实现模拟城市的类似效果, 我们该如何选择 ?

需求分析

既然我们的目标是类似<模拟城市>的效果, 那最好的办法就是看下它是如何实现的。这里我们借助AdrenoProfiler对手机上的SimCity<模拟城市>进行抓帧取样, 看下具体的实现过程。这里细节不介绍, 想了解这个工具的看下这里。
在其中绘制地面的一个Shader中我们看到这样一段:

1
2
3
4
5
6
7
8
9
10
lowp float shadowTest(highp vec3 texCoord)
{
lowp float shadow = 1.0;
const highp float depthOffset = 0.002;

highp float shadowDepth = texture2D(inSampler3, texCoord.xy).r;
//if (texCoord.z > shadowDepth) shadow = 0.0;
return step(texCoord.z, shadowDepth + depthOffset);
//return shadow;
}

有经验的开发者看到这段代码就能猜出它的实现方式,没错确实是Shadowmap。结合下面我们在导出的Texture中的这样图, 基本确认无疑(大家注意看图片的边缘留了一个像素左右的白边, 后面讲原因):
Alt text
这张是当前视窗下的深度图, 根据这个图上渠道的RGB对渲染的Fragment做颜色计算, 也就是下面这段(关键代码):

1
2
3
4
5
6
7
8
9
10
void main(void)
{

lowp float shadow;
shadow = shadowTest(outDepth);
......
lowp vec4 lighting = vec4(outColourAmb + ppl * shadow * outColourDif, 1.0);
......
gl_FragColor = res;
}

实现过程

那大致明白了目标游戏的实现方法了, 我们得在Unity下实现类似的流程。大致细分为如下这几步:

  • 如何获屏幕内容的深度图 ?
  • 如何对深度图进行采样计算阴影面积 ?

问题1:如何获屏幕内容的深度图 ?
Unity下获取深度的方式有很多种,有很多文章可以参考。这里不细讲,主要讲下这几点考虑:
1, 使用Unity自身提供的_CameraDepthTexture可以很方便的获取到深度图,但是开启深度渲染会导致场景内DrawCall翻倍,场景内物件较多这个方法不考虑。
2, 用两个摄像机结合Camera1.RenderTexture.DepthBuffer + Camera2.RenderTexture.Color组合起来, 最后将结果Blit到屏幕上,这个没有尝试。
3, 使用独立一个摄像机,在灯光坐标系下对整个场景渲染深度图
我这个采用的是第三种方法, 关键的实现代码如下:
对获取深度信息的摄像机挂载脚本, 使用RenderWithShader在Update中获取场景实时渲染的深度图。这里要注意计算最小视椎体可以提高阴影的质量, 还可以看下对RenderTexture的Anti-Aliasing进行设置:

1
2
3
4
5
6
7
8
9
10
11
12
13
v2f vert(appdata_base v) 
{
v2f o;
o.position = UnityObjectToClipPos(v.vertex);
o.depth = o.position.zw;
return o;
}

float4 frag(v2f i) : COLOR
{
float value = i.depth.x / i.depth.y > 0 ? 0 : 1;
return fixed4(value, value, value, 1);
}

在C#中对主摄像机的视椎体进行计算, 然后初始化一个最小的Orthogrphic 远近Clip Plane。主要是UpdateClipPlane 和 UpdateDepthCamera函数中有实现。

在C#下将光源坐标ViewProjection坐标系传递到Shader中, 方便做采样时坐标系的转换:

1
2
3
4
Matrix4x4 world2View = m_DepthCamera.worldToCameraMatrix;
Matrix4x4 projection = GL.GetGPUProjectionMatrix(m_DepthCamera.projectionMatrix, false);
m_LightVPMatrix = projection * world2View;
Shader.SetGlobalMatrix("_LightViewClipMatrix", m_LightVPMatrix);

对需要投影的屏平面或物体获取深度图像素信息之后进行计算:

1
2
3
4
5
6
7
//Comppute in light space.
float4 posInLight = ComputeScreenPosInLight(mul(_LightViewClipMatrix, float4(i.worldPos, 1)));
float depth = tex2D(_ShadowDepthTex, posInLight.xy/ posInLight.w).r;

//Discard border. Use glTexParameterfv(GL_TEXTURE_2D, GL_TEXTURE_BORDER_COLOR, color) in OpenGL.
float white = posInLight.x < 0.0 ? 1.0 : (posInLight.x > 1.0 ? 1.0 : (posInLight.y < 0.0 ? 1.0 : (posInLight.y > 1.0 ? 1.0 : 0.0)));
if (white > 0.5) depth = 1.f;

这里是个小技巧, 如果不做Discard border 的处理会出现如下的效果:
Alt text
Unity中对RenderTexture的Clamp是如下定义:

1
2
TextureWrapMode.Clamp
Clamps the texture to the last pixel at the edge.

因为RenderTexture的Wrap Mode选择了Clamp之后, 如果碰到超出UV范围的边界获取颜色, 就会默认取最边缘的像素。之前版本中提供的SetBorderColor已经飞起, 所以无法像OpenGL中设置texture的边缘颜色, 也是上面提到<模拟城市>的深度贴图对边缘都做了一像素的留白效果也也正是这个方法。这里我们做个小技巧, 超过边缘范围的默认depth = 1。
最终效果如下:
Alt text

遗留问题

还有很多细节的问题篇幅问题没有在这里讨论, 后续计划再详细展开几篇对阴影这块的实现,这里感觉还是蛮有料的。例如:Planar Shadow、Shadow Volume的实现,已经Shaodw Ance、Peter Panning等问题。
PS:实现的过程中也再次发现已经眼高手低的问题, 很多细节都是一知半解。通过这些实现感觉可以把一些流程理清楚很多,一点点积累把!

Talk Is Cheap !

本文涉及所有代码皆上传Github

https://github.com/zpzsoft/ShadowMap