Signed Distance Field Shadow in Unity
0x00 前言
最近读到了一个今年GDC上很棒的分享,是Sebastian Aaltonen带来的利用Ray-tracing实现一些有趣的效果的分享。
其中有一段他介绍到了对Signed Distance Field Shadow的改进,主要体现在消除SDF阴影的一些artifact上。
第一次看到Signed Distance Field Shadow是在大神Inigo Quilez的博客上,较传统的阴影实现方式,例如shadow map,视觉效果要好很多。可以看到下图中物体的阴影随着距离由近到远也逐渐由清晰渐渐过渡到模糊的效果,表现更加自然而真实。
相比较而言,Unity中的阴影实现效果就简单并且死板了许多。
下面我们就在Unity中来实现RayMarching,并利用SDF绘制一些简单的物体,最后实现一下阴影的效果。
0x01 在Unity中实现SDF
首先,RayMarching算法处理的是屏幕上的每一个像素,因此在Unity中我们自然而然会想到利用屏幕后处理的方式来实现RayMarching。
所以,RayMarching的主要逻辑都在Fragment Shader内实现,而Vertex Shader则主要用来获取顶点属性中所保存的射线信息,之后经过插值传入Fragment Shader中,供每一个Fragment来使用。此时整个屏幕是一个四边形,一共有4个顶点,这4个顶点就可以用来记录屏幕上的4根射线,而这4根射线的方向就可以直接取摄像机的平截头体的4条边的方向,之后再经过插值生成射向某个片元的射线。

这里我们可以直接调用Unity提供的Camera.CalculateFrustumCorners方法,这里是相关文档(https://docs.unity3d.com/ScriptReference/Camera.CalculateFrustumCorners.html)。
下面是这个方法的签名:
public void CalculateFrustumCorners(Rect viewport, float z,
Camera.MonoOrStereoscopicEye eye, Vector3[] outCorners);
其中作为我们需要的4个outCorners也是作为参数传入这个方法的。不过需要注意的是该方法获取的平截头体的4条边是在local space的,所以我们需要将它们转移到world space,以供Fragment Shader中使用。
这样我们就得到了4个向量,但是这4个向量要怎么向Shader中传递效率才高呢?如果每一个向量传递一次,则效率并不高。所以这里我们使用一个矩阵来保存这4个向量,而向shader中传送数据就只需要传送一个矩阵。
Transform camtr = cam.transform;
Vector3[] frustumCorners = new Vector3[4];
cam.CalculateFrustumCorners(new Rect(0, 0, 1, 1),
cam.farClipPlane, cam.stereoActiveEye, frustumCorners);
var bottomLeft = camtr.TransformVector(frustumCorners[0]);
var topLeft = camtr.TransformVector(frustumCorners[1]);
var topRight = camtr.TransformVector(frustumCorners[2]);
var bottomRight = camtr.TransformVector(frustumCorners[3]);
Matrix4x4 frustumCornersArray = Matrix4x4.identity;
frustumCornersArray.SetRow(0, bottomLeft);
frustumCornersArray.SetRow(1, bottomRight);
frustumCornersArray.SetRow(2, topLeft);
frustumCornersArray.SetRow(3, topRight);
return frustumCornersArray;
射线的数据准备好了,向shader中传送数据在Unity中也十分简单,只需要调用SetMatrix就好。但是这里又出现了一个新的问题,那就是shader如何正确的确定它所处理的是哪根射线呢?如果不能确定顶点所对应的射线,那么之后的插值结果就不会正确。所以在Vertex Shader中我们需要一个Index来从传入的矩阵中正确的取出射线方向。
那么Index要如何确定呢?
聪明的你一定想到了,对一个四边形来说,它的UV数据是很有规律的。所以我们就可以在Vertex Shader中利用UV数据来确定正确的射线:
index = v.uv.x + (2 * o.uv.y);
o.ray = _Corners[index].xyz;
OK,之后只要在Fragment Shader中使用经过插值的ray数据,就能获取当前Fragment所对应的射线方向了。到此,我们已经将射线引入了Shader中。
接下来我们来定义一个SDF,使用SDF来定义我们将要渲染的内容。我们可以在Inigo Quilez的博客上获取很多常见物体的SDF定义,链接在这里:(http://.org/www/articles/distfunctions/distfunctions.htm)。
下面我们就在Unity中利用SDF渲染一个六棱体:
float sdHexPrism( float3 p, float2 h )
{
float3 q = abs(p);
return max(q.z-h.y,max((q.x*0.866025+q.y*0.5),q.y)-h.x);
}
针对不同的物体定义都需要一个SDF来描述该物体,但是如果在我们的RayMarching算法中每次想要渲染不同的形状时都要修改一下SDF的话似乎十分不方便,所以通常我们还会定义一个更高层的抽象——也可以叫做SDF函数——这个函数常常被称作map,它的输入是一个点坐标,输出则是该点距离SDF所定义的物体表面的最近距离。
而有了map这个高层的抽象,我们可以很方便的在map的内部实现中按照自己的需求修改SDF,例如将一些基础的物体进行合并、拆分等等。从这个角度讲,map其实定义了我们要渲染的整改场景,因此正个场景的信息我们是已知的,这一点在之后渲染阴影的时候会用到。
不过,我们还是先来看一个简单的例子,下面就是我们画六棱体的例子中所使用的map的定义:
float map(float3 rp)
{
float ret = sdHexPrism(rp, float2(4, 5));
return ret;
}
之后我们在Fragment Shader中实现该Fragment上的RayMarching逻辑,在引入SDF之后,RayMarching的每一次Marching的距离就可以根据SDF的结果来设定了,我想大家应该都见过类似这样的图解:
可以看到,每一次marching的距离就是当前采样点到SDF定义的表面的最近距离,直到采样点和表面重合,即光线和表面相交了。
所以我们只需要在Fragment Shader中跑一个for循环,每一次迭代都调用一次map来确认当前采样点距离SDF的最近距离surfaceDistance,如果surfaceDistance不为0,则下一次marching的距离就是surfaceDistance;如果为0,则证明光线和表面相交,我们只需要确定这点的颜色就好了。
除此之外,我们需要相机的位置rayOrigin做为射线的起点,这个值我们可以通过在脚本中调用SetVector将相机的位置传给GPU。此外我们还需要该Fragment上的射线方向rayDirection,我们可以直接获取,因为它就是顶点属性中的ray经过插值之后的结果。
所以这是一个很简单的逻辑:
fixed4 raymarching(float3 rayOrigin, float3 rayDirection)
{
fixed4 ret = fixed4(0, 0, 0, 0);
int maxStep = 64;
float rayDistance = 0;
for(int i = 0; i < maxStep; I++)
{
float3 p = rayOrigin + rayDirection * rayDistance;
float surfaceDistance = map(p);
if(surfaceDistance < 0.001)
{
ret = fixed4(1, 0, 0, 1);
break;
}
rayDistance += surfaceDistance;
}
return ret;
}
OK,光线和表面相交之后,输出一个红色。
我们来看一下实际的结果:
可以看到,场景的Hierachy中空空如也,但是屏幕上却出现了一个纯色的六棱体。
0x02 梯度、法线和光照
当然,这个效果并不吸引人,因此我们显然要加入一些光照效果来提升表现力。那么求表面的法线就是必须要做的一件事情了。
milo的《用 C 语言画光(四):反射 》这篇文章中也有相关的内容,即距离场变化最大的方向便是法线方向。根据矢量微积分(vector calculus),一个纯量场(scalar field)的最大变化方向就是其梯度(gradient),所以这个问题就转化为求形状边界位置的 SDF 梯度——即求各个方向的变化率,也就是要求导了。
不过我们显然没有必要真正的计算求导,只需要找一个能够得到近似效果的方式就好了。我们常常使用这个下面这个算式来近似SDF梯度,即在这一点的表面法线:
代码也就十分简单了:
//计算法线
float3 calcNorm(float3 p)
{
float eps = 0.001;
float3 norm = float3(
map(p + float3(eps, 0, 0)) - map(p - float3(eps, 0, 0)),
map(p + float3(0, eps, 0)) - map(p - float3(0, eps, 0)),
map(p + float3(0, 0, eps)) - map(p - float3(0, 0, eps))
);
return normalize(norm);
}
我们可以把法线信息输出成颜色,就得到了下图中的结果。
而实现一个简单的漫反射也是一件十分简单的事情:
ret = dot(-_LightDir, calcNorm(p));
ret.a = 1;
这样我们就获得一个有简单光照效果的六棱体了。
0x03 阴影
六棱体上有了简单的漫反射效果,接下来就要在此基础上实现基于SDF的阴影效果了。SDF的一个优势就在于场景内的距离信息全都是可知的,因此可以很方便地用来实现类似阴影这样的效果,并且可以根据距离来更自然地实现阴影的衰减,从而生成一个更加真实的阴影。
不过在此之前,我会将场景修改的稍微复杂一点,当然,这里我只是增加了3个物体的SDF的定义——Sphere、Plane和Cube,并且简单的修改下map函数,重新组织了一下整个场景。
float sdSphere(float3 rp, float3 c, float r)
{
return distance(rp,c)-r;
}
float sdCube( float3 p, float3 b, float r )
{
return length(max(abs(p)-b,0.0))-r;
}
float sdPlane( float3 p )
{
return p.y + 1;
}
float map(float3 rp)
{
float ret;
float sp = sdSphere(rp, float3(1.0,0.0,0.0), 1.0);
float sp2 = sdSphere(rp, float3(1.0,2.0,0.0), 1.0);
float cb = sdCube(rp+float3(2.1,-1.0,0.0), float3(2.0,2.0, 2.0), 0.0);
float py = sdPlane(rp.y);
ret = (sp < py) ? sp : py;
ret = (ret < sp2) ? ret : sp2;
ret = (ret < cb) ? ret : cb;
return ret;
}
这样,整个场景就变成了这个样子,由2个球体和1个正方体以及一个平面组成。
接下来我们来实现阴影,其实阴影的形成本身也很简单。沿着光线的方向,如果光线被某个表面遮挡则会在后面的表面上生成阴影。
那么在代码中,一个简单的基于SDF的阴影实现就很简单了:针对到达物体表面的采样点,以该点为起点,沿着光线来的方向,发射另一根射向光源的射线。如果这根射线也击中了某个物体的表面,则证明该采样点处于阴影之中——其实还是raymarching。
下面我们来完成一个最简单的阴影实现,即阴影中是统一的黑色。
float calcShadow(float3 rayOrigin, float3 rayDirection)
{
int maxDistance = 64;
float rayDistance = 0.01;
for(rayDistance ; rayDistance < maxDistance;)
{
float3 p = rayOrigin + rayDirection * rayDistance;
float surfaceDistance = map(p);
if(surfaceDistance < 0.001)
{
return 0.0;
}
rayDistance += surfaceDistance;
}
return 1.0;
}
当然这里需要注意的是,第一次迭代时不要直接把采样点传入到map中,否则的话会直接return。
ok,这样一个很硬的阴影就创建好了,没有多余的pass,没有多余的贴图,使用SDF创建阴影就是这么简单。
大家都知道,阴影通常是由所谓的本影和半影组成的,其中本影主要指的是物体表面上那些没有被光源直接照射的区域,呈现全黑的状态,而所谓的半影则是那些半明半暗的过渡部分。可以看到我们实现的这种阴影其实只包括本影,而没有半影的效果。
所以在这个纯黑的本影的基础上,再增加一些不是纯黑的半影效果,那么最后的阴影会更加真实。所以接下来我们就要考虑,黑色本影之外的表面上的那些点的颜色了。
这时我们把距离的因素考虑进去:
ret = min(ret, 10 * surfaceDistance /rayDistance );
可以看到,这样一来在之前纯黑的本影之外,不再是像最初的实现中将影子直接截断,而是多了一圈模糊的半影来过渡。
不过,我相信眼尖的你一定发现了一些问题。那就是Cube的半影部分出现了条带状的artifact。
这主要是由于在计算阴影的RayMarching的过程中,采样出现了问题。
在今年的GDC上,Sebastian Aaltonen分享了一个新的方案来解决这个问题:
根据上一次的采样D-1和这一次的采样D的数据,来计算或者是估算一个这条射线上距离SDF表面最近的点E,并用E来计算半影。
在分享中Sebastian也给出了他修改后的半影计算公式:
Triangulation formula: res = min(res,
(r2*sqrt(4*(r1*r1)-h*h))*rcp(2*hprev)/(t-h*h*rcp(2*hprev)))
事实上Inigo也已经根据Sebastian的分享,改进了他的SDF阴影的效果。下面我们就根据Inigo和Sebastian的实现,在Unity中解决掉这个半影部分的条带状的artifact吧。
//Adapted from:iquilezles
float calcSoftshadow( float3 ro, float3 rd, float mint, float tmax)
{
float res = 1.0;
float t = mint;
float ph = 1e10;
for( int i=0; i<32; i++ )
{
float h = map( ro + rd*t );
float y = h*h/(2.0*ph);
float d = sqrt(h*h-y*y);
res = min( res, 10.0*d/max(0.0,t-y) );
ph = h;
t += h;
if( res<0.0001 || t>tmax )
break;
}
return clamp( res, 0.0, 1.0 );
}
其中ph是上一次采样时的圆形的半径,h是当前这次的采样的圆形半径。
修改后的阴影效果:
0x04 后记
这样,我们就在Unity中实现了SDF渲染以及基于SDF的阴影渲染,并且解决了讨厌的条带状的artifact。
本文的项目可以在这里获取:
https://github.com/chenjd/Unity-Signed-Distance-Field-Shadow