前言

在上一篇中,我们实现了一个最基本的Shadow Mapping阴影,并且通过偏移和背面裁切对阴影的错误进行了修正。现在我们要将阴影的质量继续提高。

抗锯齿

仔细观察生成的阴影边缘,可以看到很严重的锯齿,这是因为采样的shadow map分辨率不够,屏幕上多个片元对同一个位置采样造成的。此外,一般我们在现实中看见的影子一般都是这样的:
请添加图片描述
而我们渲染出来的影子是这样的:
请添加图片描述

可以看到,我们渲染出来的结果相比于现实,一是有着明显的锯齿,二是阴影的轮廓太硬了,所以我们需要一种方法,柔化阴影边缘,去除锯齿。

PCF(Percent closer Filter)

算法原理

根据shadow map的对比算法,一个片元是否处于阴影取决于其在灯光坐标系下的深度与是否高于shadow map记录的深度,返回的值相当于是布尔类型:不是1就是0,这就导致了阴影在边缘上的明显锯齿。如果我们能在阴影和非阴影的交界处创造一个柔和的过渡,就能消除僵硬的锯齿边缘。

PCF提出了这么一种方法:当我们计算出片元的灯光坐标后,将其与shadow map中一片区域中的所有深度都进行对比,然后取均值作为结果。例如,我们取一块3x3的区域,使用片元的深度逐一比较,求结果的平均值,我们就能生成9种可能性,这相比原本2种可能性,有了更多缓和的空间。
请添加图片描述

实现

让我们修改计算阴影的shader,首先将整个用于PCF采样的方法写成一个函数,方便我们在片元着色器里调用。

float pcfSample3x3(float lightCoordDepth, half2 uv) {
    float shadow = 0;
    for (int i = 0; i < 3; ++i) {
        for (int j = 0; j < 3; ++j) {
            float depth = SAMPLE_DEPTH_TEXTURE(_gShadowTexture, 
                uv + half2(i - 1, j - 1) * _gShadowTexture_TexelSize.xy);
        #if defined(UNITY_REVERSED_Z)
            depth = 1 - depth;
        #endif
            shadow += (depth < lightCoordDepth) ? 0 : 1;
        }
    }
    shadow /= 9;
    return shadow;
}

在该方法里,我们以指定的UV为中心,围绕其对范围内的shadow map深度进行采样,并将结果累加,最后除以采样的数量取得平均值,将结果返回。我们使用_gShadowTexture_TexelSize.xy来偏移UV坐标,该结构储存了在_gShadowTexture纹理中一个像素所占的uv宽、高度。

然后我们修改片元着色器,首先计算片元的灯光坐标系深度,然后构造uv来进行采样:

fixed4 frag (v2f i) : SV_Target{
    ...
    //偏移采样深度,避开shadow acne
    //float shadow = (depth < lightCoordDepth - bias) ? 0 : 1;
    ///使用PCF采样计算shadow
    float shadow = pcfSample3x3(lightCoordDepth - bias, uv);
    ...
}

对比下效果:
请添加图片描述
嗯嗯嗯…总感觉不对,让我们试试更大的采样范围(图为7x7的采样范围):
请添加图片描述

可以看到,在使用了PCF之后,阴影开始出现条带感,因为采样范围变大的原因,使得半影宽度增大了,但是半影的灰阶不够多,就像看色彩范围不够的旧显示器一样。
我们可以通过缩小采样的范围来减少半影的宽度:

//注意结尾的*0.1,相当于PCF采样移动的移动距离为0.1个像素
float depth = SAMPLE_DEPTH_TEXTURE(_gShadowTexture, 
uv + half2(i - 3, j - 3) * _gShadowTexture_TexelSize.xy * 0.1);

请添加图片描述
可以观察到,当PCF的采样距离缩小后,不会出现条带状阴影了,倒是锯齿又出来了…

使用硬件支持的PCF

由于Shadow Mapping技术在即时渲染领域的广泛使用,PCF采样和阴影深度对比成了一个常用的技术.因此,显卡厂商专门针对这项技术,提供了硬件上的支持。

要在Unity中使用这项技术,首先我们要将采集阴影使用的RenderTexture类型改为ShadowMap格式:

shadowTexture = new RenderTexture(shadowResolution, shadowResolution, 24, RenderTextureFormat.Shadowmap);

在shader中,我们使用Unity预定义宏来声明纹理:

//sampler2D _gShadowTexture;
//使用Unity预定义宏来声明纹理
UNITY_DECLARE_SHADOWMAP(_gShadowTexture);

在片元着色器中,我们将传入一个float3类型的坐标,xy代表着uv位置,而z代表需要对比的深度。采样器将会自动对该位置进行2x2的PCF采样,并将结果使用线性插值计算后返回。

//构建灯光坐标系下的NDC坐标,作为UV进行深度采样
float3 coord = i.shadowPos.xyz / i.shadowPos.w;
coord.xy = uv.xy * 0.5 + 0.5;


//指向灯光方向
float3 worldLightDir = normalize(UnityWorldSpaceLightDir(i.worldPos));
//bias = tan(cos(n·l)),其中n为法向,l指向灯光
//我们使用acos(n·l)来获取夹角,然后再求正交值
float bias = _gShadowBias * tan(acos(saturate(dot(worldLightDir, i.worldNormal))));
bias = clamp(bias, 0, 0.01);
//提前在对比深度中加入偏移量
coord.z += bias;
float depth = UNITY_SAMPLE_SHADOW(_gShadowTexture, uv);

可以看到,主要的工作都被Unity定义的宏完成了,那么这两个宏分别做了什么工作呢?我们可以在HLSLSupport.cginc文件中看到相关定义(下面的代码来源于Unity 2020.3.4f1 Shader源代码):

// Macros to declare and sample shadow maps.  
//  
// UNITY_DECLARE_SHADOWMAP declares a shadowmap.  
// UNITY_SAMPLE_SHADOW samples with a float3 coordinate (UV > in xy, Z in z) and returns 0..1 scalar result.  
// UNITY_SAMPLE_SHADOW_PROJ samples with a projected > coordinate (UV and Z divided by w).  

上面几句说明了三个宏的作用,UNITY_DECLARE_SHADOWMAP用于声明一个shadow map;UNITY_SAMPLE_SHADOW使用一个float3类型坐标来采样,xy是uv,z是深度,返回0~1的标量表示是否在阴影中;UNITY_SAMPLE_SHADOW_PROJ使用变换到灯光坐标系的其次坐标来采样,自动帮你做了透视除法。

#if !defined(SHADER_API_GLES)
    // all platforms except GLES2.0 have built-in shadow comparison samplers
    #define SHADOWS_NATIVE
#elif defined(SHADER_API_GLES) && defined(UNITY_ENABLE_NATIVE_SHADOW_LOOKUPS)
    // GLES2.0 also has built-in shadow comparison samplers, but only on platforms where we pass UNITY_ENABLE_NATIVE_SHADOW_LOOKUPS from the editor
    #define SHADOWS_NATIVE
#endif

这里使用宏来标记在哪些平台上可以使用阴影比较采样器(shadow comparison samplers)。

#if defined(SHADER_API_D3D11) || (defined(UNITY_COMPILER_HLSLCC) && defined(SHADOWS_NATIVE)) || defined(SHADER_API_PSSL)
    // DX11 & hlslcc platforms and PS4: built-in PCF
    #define UNITY_DECLARE_SHADOWMAP(tex) Texture2D_float tex; SamplerComparisonState sampler##tex
    #define UNITY_DECLARE_TEXCUBE_SHADOWMAP(tex) TextureCube_float tex; SamplerComparisonState sampler##tex
    #define UNITY_SAMPLE_SHADOW(tex,coord) tex.SampleCmpLevelZero (sampler##tex,(coord).xy,(coord).z)
    #define UNITY_SAMPLE_SHADOW_PROJ(tex,coord) tex.SampleCmpLevelZero (sampler##tex,(coord).xy/(coord).w,(coord).z/(coord).w)
    #if defined(SHADER_API_GLCORE) || defined(SHADER_API_GLES3) || defined(SHADER_API_VULKAN) || defined(SHADER_API_SWITCH)
        // GLSL does not have textureLod(samplerCubeShadow, ...) support. GLES2 does not have core support for samplerCubeShadow, so we ignore it.
        #define UNITY_SAMPLE_TEXCUBE_SHADOW(tex,coord) tex.SampleCmp (sampler##tex,(coord).xyz,(coord).w)
    #else
       #define UNITY_SAMPLE_TEXCUBE_SHADOW(tex,coord) tex.SampleCmpLevelZero (sampler##tex,(coord).xyz,(coord).w)
    #endif
#else
    // Fallback / No built-in shadowmap comparison sampling: regular texture sample and do manual depth comparison
    #define UNITY_DECLARE_SHADOWMAP(tex) sampler2D_float tex
    #define UNITY_DECLARE_TEXCUBE_SHADOWMAP(tex) samplerCUBE_float tex
    #define UNITY_SAMPLE_SHADOW(tex,coord) ((SAMPLE_DEPTH_TEXTURE(tex,(coord).xy) < (coord).z) ? 0.0 : 1.0)
    #define UNITY_SAMPLE_SHADOW_PROJ(tex,coord) ((SAMPLE_DEPTH_TEXTURE_PROJ(tex,UNITY_PROJ_COORD(coord)) < ((coord).z/(coord).w)) ? 0.0 : 1.0)
    #define UNITY_SAMPLE_TEXCUBE_SHADOW(tex,coord) ((SAMPLE_DEPTH_CUBE_TEXTURE(tex,(coord).xyz) < (coord).w) ? 0.0 : 1.0)
#endif

重头戏在这里,可以看到,要开启硬件PCF,必须是DX11或者是支持阴影比较采样器的HLSLCC平台,以及PS4平台。在支持的平台上,将使用tex.SampleCmpLevelZero指令进行采样,关于该指令的功能,参考这里。如果是不支持的平台,将直接退化为普通的采样对比:

((SAMPLE_DEPTH_TEXTURE(tex,(coord).xy) < (coord).z) ? 0.0 : 1.0)

这和我们之前的直接比较深度是一致的。
在使用了硬件支持的PCF功能后,我们能获得如下的结果:
请添加图片描述

进一步:关于硬件支持的PCF

目前几乎所有的显卡设备都能提供PCF的支持了。值得注意的是,显卡一般支持的是2x2 PCF,但是却能得出我们实现的3x3 PCF还要顺滑的结果,引用SIGGRAPH 2005的一张图:
请添加图片描述

这是因为硬件对阴影进行了双线性插值,其原理和双线性纹理采样是一致的,只是采样的目标由纹理换成了对shadow map的深度比较结果。

也许有人会好奇,上一章我们明明开启了shadow map的插值过滤,为什么结果还是如此生硬呢?那是因为设置shadow map的过滤模式,只是影响了采样深度的值,而这里产生硬边的根本原因发生在比较操作上,所以我们对shadow map做什么操作都不会出现软阴影,只会影响硬边的形状和大小:

请添加图片描述
我们可以根据双线性插值的原理,实现一个软件版的阴影采样器,具体原理网上资料很多,这里不再赘述。在采样时要注意,我们采样的不是shadow map,而是比较的阴影结果:

float sampleShadow(float3 coord) {
    float depth = SAMPLE_DEPTH_TEXTURE(_gShadowTexture, coord.xy);
#if defined(UNITY_REVERSED_Z)
    depth = 1 - depth;
#endif
    return (depth < coord.z) ? 0 : 1;
}

float pcfSample2x2Bilinear(float3 coord) {
    //将UV转换到Shadow Map纹素空间
    float2 pixelCoord = coord.xy * _gShadowTexture_TexelSize.zw;
    float2 leftBottom = floor(pixelCoord);
    float2 rightTop = leftBottom + float2(1, 1);

    float4 weight = float4(
        (rightTop.x - pixelCoord.x) * (pixelCoord.y - leftBottom.y),
        (pixelCoord.x - leftBottom.x) * (pixelCoord.y - leftBottom.y),
        (rightTop.x - pixelCoord.x) * (rightTop.y - pixelCoord.y),
        (pixelCoord.x - leftBottom.x) * (rightTop.y - pixelCoord.y)
    );
    float4 shadow = 1;

    shadow.z = sampleShadow(float3(leftBottom * _gShadowTexture_TexelSize.xy, coord.z));
    shadow.w = sampleShadow(float3(float2(rightTop.x, leftBottom.y) * _gShadowTexture_TexelSize.xy, coord.z));
    shadow.x = sampleShadow(float3(float2(leftBottom.x, rightTop.y) * _gShadowTexture_TexelSize.xy, coord.z));
    shadow.y = sampleShadow(float3(rightTop * _gShadowTexture_TexelSize.xy, coord.z));

    return dot(shadow, weight);
}

fixed4 frag (v2f i) : SV_Target{
    ...
    float shadow = pcfSample2x2Bilinear(float3(coord.xy, lightCoordDepth - bias));
    ...
}

请添加图片描述

最后的结果和硬件计PCF有些细微差别(有知道原因的大佬请赐教)。当然实际情况下我们肯定选择使用硬件支持的做法,能提高不少的性能。

更进一步:Unity中的阴影采样

在Unity中,要对shadow map进行采样使用的是SHADOW_ATTENUATION宏,该宏在AutoLight.cginc中有定义:

#if defined (SHADOWS_SCREEN)
    ...
    #define SHADOW_ATTENUATION(a) unitySampleShadow(a._ShadowCoord)
#endif

// -----------------------------
//  Light/Shadow helpers (4.x version)
// -----------------------------
// This version computes light coordinates in the vertex shader and passes them to the fragment shader.

// ---- Spot light shadows
#if defined (SHADOWS_DEPTH) && defined (SPOT)
...
#define SHADOW_ATTENUATION(a) UnitySampleShadowmap(a._ShadowCoord)
#endif

// ---- Point light shadows
#if defined (SHADOWS_CUBE)
...
#define SHADOW_ATTENUATION(a) UnitySampleShadowmap(a._ShadowCoord)
...
#endif

// ---- Shadows off
#if !defined (SHADOWS_SCREEN) && !defined (SHADOWS_DEPTH) && !defined (SHADOWS_CUBE)
...
#define SHADOW_ATTENUATION(a) 1.0
...
#else
...
#endif
#endif

这里我们可以看到宏的注释中说明了聚光灯和点光源的阴影计算都是使用UnitySampleShadowmap函数完成的,但是却没有说明方向光。

实际上方向光是由SHADOWS_SCREEN宏来控制的,根据Unity论坛上What does SHADOWS_SCREEN mean?的讨论,SHADOWS_SCREEN宏与场景的主要平行光阴影相关。

unitySampleShadow函数的实现首先判断是否使用屏幕空间阴影,如果是则使用UNITY_SAMPLE_SCREEN_SHADOW来采样阴影,由于阴影已经事先在屏幕空间计算好了,这里相当于直接用片元的屏幕坐标采样阴影。
如果不使用屏幕空间阴影技术,则是将片元坐标转换到灯光坐标系(unity中叫作阴影坐标系),然后根据平台是否支持阴影比较采样器,分别使用UNITY_SAMPLE_SHADOW或者SAMPLE_DEPTH_TEXTURE来采样。

unitySampleShadow

屏幕空间阴影

UNITY_SAMPLE_SCREEN_SHADOW

支持阴影比较采样器

UNITY_SAMPLE_SHADOW

SAMPLE_DEPTH_TEXTURE

对于聚光灯和点光源,它们使用的UnitySampleShadowmap函数有是什么呢?该函数定义在UnityShadowLibrary.cginc下,这里就不再放出代码了,有兴趣的读者可以自行去unity官网下载查看。这个函数主要做了如下事情:

UnitySampleShadowmap

软阴影

支持阴影比较采样器

UNITY_SAMPLE_SHADOW_PROJ

SAMPLE_DEPTH_TEXTURE_PROJ

支持阴影比较采样器

基于SAMPLE_DEPTH_TEXTURE的4-tap PCF均值采样

移动端平台

基于UNITY_SAMPLE_SHADOW的4-tap均值采样

UnitySampleShadowmap_PCF3x3

是否启用软阴影的最大区别在于需不需要使用4-tap采样均值,这个操作将采样点向四个方向偏移采样,并计算平均结果。对于我们最常见的PC和主机平台,最后将使用UnitySampleShadowmap_PCF3x3函数进行处理,这个函数将直接返回UnitySampleShadowmap_PCF3x3Tent函数的返回值:

half UnitySampleShadowmap_PCF3x3(float4 coord, float3 receiverPlaneDepthBias)
{
    return UnitySampleShadowmap_PCF3x3Tent(coord, receiverPlaneDepthBias);
}

而在UnitySampleShadowmap_PCF3x3Tent函数中,首先判断平台是否支持阴影比较采样器,如果不支持,直接使用3x3核的PCF均值采样(很遗憾我这里不能搭建平台做测试,因为我手头上的设备都支持阴影比较采样器…),而对于最后的情况,即在使用软阴影,平台支持硬件PCF且非移动平台下,事情就比较有趣了,我们单独拿出一节来分析。

PCF采样优化

之前的实践中我们发现,即使使用硬件支持的PCF采样,也很难消除shadow map低分辨率下的锯齿,因此我们需要扩大pcf的采样范围,比如Unity默认实现中的3x3大小的核。

首先,我们可以用一种比较有趣的方式将硬件PCF融入进来:我们向采样点的四个对角,横向纵向分别移动0.5个像素:
请添加图片描述
使用这四个点进行PCF采样,并将结果求平均值。这是一种很经典的做法,在许多后效上都使用了这个方式进行采样。由于部分像素被多次采样,所以每个像素的颜色对最终颜色的贡献占比不同:
请添加图片描述
这个归一化后3x3的核就是我们的过滤器(Filter),也可以称为卷积核,而我们在做的事相当于是对shadow map比较后的信号进行过滤,或者说对原始阴影图的卷积。我们以核心为采样原点,对周围的像素进行采样,并乘以权重后相加。

虽然这个核的尺寸为3x3,但是我们只用了4个抽头(4-tap),即只进行了四次采样。采样数在纹理相关的运算中非常关键,因为每一次采样,GPU都需要寻址后进行插值、Wrap、Mipmap计算等操作,因此,如果能减少采样的次数,算法的性能将会更高。

使用该方法实现3x3 PCF过滤的关键代码如下:

...

//使用Unity预定义宏来声明阴影纹理
UNITY_DECLARE_SHADOWMAP(_gShadowTexture);
half4 _gShadowTexture_TexelSize;
...
float pcfSample3x3(float3 coord) {
    float offsetX = _gShadowTexture_TexelSize.x * 0.5;
    float offsetY = _gShadowTexture_TexelSize.y * 0.5;
    float4 result = 0;
    result.x = UNITY_SAMPLE_SHADOW(_gShadowTexture, coord + float3(-offsetX, -offsetY, 0));
    result.y = UNITY_SAMPLE_SHADOW(_gShadowTexture, coord + float3( offsetX, -offsetY, 0));
    result.z = UNITY_SAMPLE_SHADOW(_gShadowTexture, coord + float3(-offsetX,  offsetY, 0));
    result.w = UNITY_SAMPLE_SHADOW(_gShadowTexture, coord + float3( offsetX,  offsetY, 0));
    return dot(result, 0.25);
}

fixed4 frag (v2f i) : SV_Target{
    ...
    float shadow = pcfSample3x3(coord);
    ...
}

渲染结果如下:
请添加图片描述

TentFilter

线性插值的问题

虽然阴影更柔和了一些,但是还是能观察到半影两侧有明显的边界感。这个边界感是怎么来的呢?假设在一维下对两个像素进行采样,设采样点为两个像素的中点,x轴以采样点为原点,y轴是shadow值,原始数据是两个离散的点:
请添加图片描述
默认的PCF使用双线性插值就是在两个离散点上建立了联系,使得[-0.5,0.5]区间取得连续的数值:
请添加图片描述
我们用数学公式来表达,该函数f(x)表示如下:

f ( x ) = { 0 , x < − 0.5 x + 0.5 , − 0.5 ≤ x < 0.5 1 , x ≥ 0.5 f(x)=\begin{cases} 0, &x<-0.5\\ x+0.5, &-0.5≤x<0.5\\ 1, &x≥0.5 \end{cases} f(x)= 0,x+0.5,1,x<0.50.5x<0.5x0.5

改进的PCF3x3采样结果如下:
请添加图片描述
让我们快速回顾一下ShadowMap技术,哪些是我们已知的呢?

  • Shadowmap包含整个场景的灯光视角深度信息;
  • 渲染视角通常在场景中观察整个场景;
  • 深度比较的结果不是0就是1。

由此可以推导出:

  1. 因为渲染视角通常观看场景的局部,而阴影贴图要渲染整个场景,而出于渲染成本和硬件限制等情况,通常阴影贴图的分辨率不能无限拉高。所以阴影的质量被ShadowMap的分辨率限制,导致锯齿产生。
  2. 因为深度结果不是0就是1,又因为阴影有锯齿,所以我们使用了PCF将过程插值,让阴影在0-1之间过渡。

然后我们感觉插值后的结果太生硬了,通过对PCF的输入输出进行数学分析后发现:It's the continuity, Stupid!

从信号处理的角度出发

问题在这里停滞了。有句名言叫做“在哪里跌倒了,就在哪里躺下,顺便打几个滚”。我们一个打滚,滚到了信号处理上。诶,问题居然就有了进展。

让我们从信号处理的角度重新审视下现状:
ShadowMap像是一个低频率的方波,而我们的视角像是对其进行高频采样。我们的目标是让采样出来的信号保持至少一阶导数是连续的。

这样看,PCF的简单插值就可以类比成一阶低通滤波器,过滤后的方波成了梯形波,它不符合一阶导数连续的要求。
请添加图片描述
那可太简单啦,轮到滤波器出马了!首先出马的是均值滤波器,然后是高斯滤波器,接着是切比雪夫滤波器…
结果呢,结果是都人仰马翻啦!原因有2种:1.还是太生硬。2.计算成本太高。好吧,看来也没那么简单嘛。在一番东翻西找之后,我们找到了一个比较接近的滤波器:帐篷滤波器(Tent Filter)。它完美的符合我们想要的所有需求:计算量不高(仅比均值滤波高一点),且信号是一阶连续的。

TentFilter的几何意义

假设我们的采样足够密集,或者我们干脆把采样过程看成连续的,那么最原始的采样结果就像方波一样:
请添加图片描述
TentFilter将采样从点变成范围,并且规定这个采样范围离采样中心越近,权重越高。假设一个坐标系x为采样位置,y为采样权重,我们就可以在这个坐标系上画出一个三角形:
请添加图片描述

转换成几何后就很好理解了。随着采样进行,先由三角形的一角采样到第二个像素,然后逐渐过渡到整个三角形采样第二个像素。如果这时候我们根据三角形在不同像素的面积比,再根据面积比计算采样颜色,就可以计算出很丝滑的过渡颜色了。

TentFilter的原理验证

现在,我们只需要计算出这个三角形在不同像素中面积的占比,就能计算出该像素颜色占最终颜色的权重是多少。 我们可以来试着计算一下:

首先我们先简化一下算法模型,免得带入太多变量,导致公式太复杂:假设一个宽度为w,高为h的三角形(代表Tent),在X上连续采样。我们有一个方波如下:
w c ( x ) = { 0 , x < 0 1 , x ≥ 0 w_c(x)=\begin{cases} 0, & x<0 \\ 1, & x \geq 0 \end{cases} wc(x)={0,1,x<0x0

设TentFilter采样函数 a ( x ) a(x) a(x),首先我们很容易可以得出:

  • x ≥ w / 2 x \geq w/2 xw/2 时, a ( x ) = 1 2 w h a(x)=\frac{1}{2}wh a(x)=21wh,因为所有采样结果都为1(可以理解成三角形都在方波中);
  • x ≤ − w / 2 x \leq -w/2 xw/2 时, a ( x ) = 0 a(x)=0 a(x)=0, 因为所有采样结果都为0(可以理解成三角形都在方波外);

然后是部分采样结果为0,部分为1的情况。这时,我们需要对三角形面积进行积分计算,获取各自的权重了。三角形面积的积分可以表示成对如下函数的不定积分:
f ( x ) = 0.5 w − ∣ x ∣ 0.5 w ∗ h ( − 0.5 w ≤ x ≤ 0.5 w ) f(x)=\frac{0.5w-|x|}{0.5w} * h \quad (-0.5w \leq x \leq 0.5w) f(x)=0.5w0.5wxh(0.5wx0.5w)

我们可以让 h = 0.5 w h=0.5w h=0.5w,简化该函数:
f ( x ) = 0.5 w − ∣ x ∣ ( − 0.5 w ≤ x ≤ 0.5 w ) f(x)=0.5w-|x| \quad (-0.5w \leq x \leq 0.5w) f(x)=0.5wx(0.5wx0.5w)

因为w为常量,我们暂时用w’代替0.5w,方便计算。
请添加图片描述
将积分的起点设置在 x = − w x=-w x=w的位置,对其进行变上限积分
a ( x ) = ∫ − w ′ x ( w ′ − ∣ t ∣ ) d t a(x)=\int_{-w'}^{x} (w'-|t|)dt a(x)=wx(wt)dt

对t分情况考虑,当 t ∈ [ − w ′ , 0 ] t \in [-w', 0] t[w,0] 时, ∣ t ∣ = − t |t| = -t t=t, 目标函数变成$ f(x)=w’+t$,则:

a ( x ) = ∫ − w x ( w ′ + t ) d t = [ w ′ t + 1 2 t 2 ] − w ′ x = ( w ′ x + 1 2 x 2 ) − [ w ′ ∗ − w ′ + 1 2 ∗ ( − w ′ ) 2 ] = w ′ x + 1 2 x 2 + 1 2 w ′ 2 \begin{align*} a(x)&=\int_{-w}^{x}(w'+t)dt \\ &=\left [ w't+\frac{1}{2}t^2 \right ]_{-w'}^{x} \\ &=\left ( w'x + \frac{1}{2}x^2 \right ) - \left [ w' * -w' + \frac{1}{2} * (-w')^2 \right ] \\ &=w'x + \frac{1}{2}x^2 + \frac{1}{2}w'^2 \end{align*} a(x)=wx(w+t)dt=[wt+21t2]wx=(wx+21x2)[ww+21(w)2]=wx+21x2+21w′2

t ∈ ( 0 , w ′ ] t \in (0, w'] t(0,w] 时, ∣ t ∣ = t |t| = t t=t, 目标函数变成$ f(x)=w’-t$,则:

a ( x ) = ∫ − 1.5 0 ( w ′ + t ) d t + ∫ 0 x ( w ′ − t ) d t = 1 2 w ′ 2 + [ w ′ t − 1 2 t 2 ] 0 x = 1 2 w ′ 2 + w ′ x − 1 2 x 2 \begin{align*} a(x)&=\int_{-1.5}^{0}(w'+t)dt + \int_{0}^{x}(w'-t)dt \\ &=\frac{1}{2}w'^2 + \left[ w't - \frac{1}{2}t^2 \right]_0^x \\ &=\frac{1}{2}w'^2 +w'x - \frac{1}{2}x^2 \end{align*} a(x)=1.50(w+t)dt+0x(wt)dt=21w′2+[wt21t2]0x=21w′2+wx21x2

结合所情况,我们可以得到如下一个复合公式(还记得吗,我们假设h=0.5w):

a ( x ) = { 0 , x < − w ′ w ′ x + 1 2 x 2 + 1 2 w ′ 2 , − w ′ ≤ x ≤ 0 w ′ x − 1 2 x 2 + 1 2 w ′ 2 , 0 < x ≤ w ′ w ′ 2 , x > w ′ a(x) = \begin{cases} 0, & x < -w' \\ w'x + \frac{1}{2}x^2 + \frac{1}{2}w'^2, & -w' \leq x \leq 0 \\ w'x - \frac{1}{2}x^2 + \frac{1}{2}w'^2, & 0 < x \leq w' \\ w'^2, & x > w' \end{cases} a(x)= 0,wx+21x2+21w′2,wx21x2+21w′2,w′2,x<wwx00<xwx>w

最后我们整个除以三角形的面积,对其进行归一化,确保结果落在[0,1]区间内:

a ( x ) = { 0 , x < − w ′ x w ′ + x 2 2 w ′ 2 + 1 2 , − w ′ ≤ x ≤ 0 x w ′ − x 2 2 w ′ 2 + 1 2 , 0 < x ≤ w ′ 1 , x > w ′ a(x) = \begin{cases} 0, & x < -w' \\ \frac{x}{w'}+\frac{x^2}{2w'^2}+\frac{1}{2}, & -w' \leq x \leq 0 \\ \frac{x}{w'}-\frac{x^2}{2w'^2}+\frac{1}{2}, & 0 < x \leq w' \\ 1, & x > w' \end{cases} a(x)= 0,wx+2w′2x2+21,wx2w′2x2+21,1,x<wwx00<xwx>w

假设我们设w=3,即设置一个宽为3,高为1.5的TentFilter核,则该核采样原始方波的结果为:

a ( x ) = { 0 , x < − 1.5 2 9 x 2 + 2 3 x + 1 2 , − 1.5 ≤ x ≤ 0 − 2 9 x 2 + 2 3 x + 1 2 , 0 < x ≤ 1.5 1 , x > 1.5 a(x) = \begin{cases} 0, & x < -1.5 \\ \frac{2}{9}x^2 + \frac{2}{3}x + \frac{1}{2}, & -1.5 \leq x \leq 0 \\ -\frac{2}{9}x^2 + \frac{2}{3}x + \frac{1}{2}, & 0 < x \leq 1.5 \\ 1, & x > 1.5 \end{cases} a(x)= 0,92x2+32x+21,92x2+32x+21,1,x<1.51.5x00<x1.5x>1.5

对其作图可以得到一个很平缓的曲线。
请添加图片描述

回到离散化

我们捣鼓完了理论,接下来就看看如何将这个理论应落地到实现中。
回顾一下,现在我们在计算屏幕上某个像素的阴影颜色,我们已经能够得到该像素对应的阴影贴图坐标。这次,我们不再应用任何PCF或其他软阴影技术,而是构建一个TentFilter卷积核,对阴影贴图的结果进行卷积采样。

我们先从最小的3x3卷积核开始。假设我们有一个3x3卷积核,让这个核在阴影贴图中自由地飞翔,点到哪儿我们就计算哪儿的颜色。
请添加图片描述

用一个词形容:灾难。看着这自由飞翔的卷积核,我的心也自由飞翔去了…

我们经常接触的屏幕后效处理的卷积,都是像素对齐的,3个像素宽那就是覆盖3个像素,几乎不会出现1.5个像素这种情况。但这次我们处理的是超采样,就是要在这像素的缝隙中间抠出更多像素。那怎么办呢,只能凉拌了。

仔细观察我们会发现,如果我们采样的像素刚好对齐ShadowMap时,一切都严丝合缝,1个采样点对一个像素。但是错开后,最多我们会覆盖到16个像素:

请添加图片描述
先别急,我们的几何工具又派上用场了。要分析这个一团糟的情况,我们要先打下一个锚点,这个锚点就是原点,来开展我们的几何计算。

  • 首先我们可以把二维问题拆解成一维问题,因为U向和V向是一致的,解决了U向,V向就能同理解决;
  • 然后我们找一个原点,分析一下几何;

回到光栅化

假设我们有一个采样点p,我们可以就近取整作为原点 C 0 C_0 C0,并重新构建该点的坐标q:

C 0 = r o u n d ( p ) q = p − C 0 C_0=round(p) \\ q = p - C_0 C0=round(p)q=pC0

要注意到在纹素坐标系中像素中心坐标的位置。例如(1, 1)并不是第一个像素中心坐标,而是第一个像素和第二个像素的边界,第一个像素的中心坐标是(1.5, 1.5)。
另外,q的取值范围是[-0.5, 0.5)。

在这个范围内卷积核的移动范围如下图:
请添加图片描述

此时坐标原点在两个像素的正中间,然后我们把二维拆成一维来分析,问题进一步缩小为在 − 0.5 ≤ x ≤ 0.5 -0.5 \leq x \leq 0.5 0.5x0.5时,TentFilter的三角形落在四个像素内的面积,该面积与整个三角形的占比就是该像素的权重。
请添加图片描述

根据几何计算,我们可以推出四个部分三角形面积S与位移x的关系如下:

S x = 1 2 ( 0.5 − x ) 2 S y = ( 1.5 − x ) − 0.5 − [ m i n ( x , 0 ) ] 2 S z = ( 1.5 + x ) − 0.5 − [ m a x ( x , 0 ) ] 2 S w = 1 2 ( 0.5 + x ) 2 \begin{align*} S_x&=\frac{1}{2}(0.5-x)^2 \\ S_y&=(1.5 - x) - 0.5 - \left[ min(x, 0) \right]^2 \\ S_z&=(1.5 + x) - 0.5 - \left[ max(x, 0) \right]^2 \\ S_w&=\frac{1}{2}(0.5+x)^2 \end{align*} SxSySzSw=21(0.5x)2=(1.5x)0.5[min(x,0)]2=(1.5+x)0.5[max(x,0)]2=21(0.5+x)2

最后,我们全部除以三角形的面积,将权重归一化,该三角形的总面积为 1 2 ∗ 3 ∗ 1.5 = 2.25 \frac{1}{2}*3*1.5=2.25 2131.5=2.25,通过转为乘法加速计算:
W x = S x ∗ 0.4444 W y = S y ∗ 0.4444 W z = S z ∗ 0.4444 W w = S w ∗ 0.4444 W_x = S_x * 0.4444 \\ W_y = S_y * 0.4444 \\ W_z = S_z * 0.4444 \\ W_w = S_w * 0.4444 Wx=Sx0.4444Wy=Sy0.4444Wz=Sz0.4444Ww=Sw0.4444

C 0 C_0 C0在4个像素的正中间,因此X、Y、Z、W四个像素的纹理坐标如下:

C X = C 0 − 1.5 C Y = C 0 − 0.5 C Z = C 0 + 0.5 C W = C 0 + 1.5 C_X = C_0-1.5 \\ C_Y = C_0-0.5 \\ C_Z = C_0+0.5 \\ C_W = C_0+1.5 CX=C01.5CY=C00.5CZ=C0+0.5CW=C0+1.5

至此,我们就完成了一维的离散TentFilter权重和采样位置的计算,可喜可贺。我们总结一下流程:

  1. 设一个采样位置p,在Shadowmap中任意位置;
  2. 将p点取整,作为原点构建新坐标系原点;
  3. 根据公式计算TentFilter在X、Y、Z、W四个区间的权重,分别计算U方向和V方向;
  4. 构建卷积核,对相关像素采样并计算结果。

PCF采样优化

大部分问题我们已经解决了,现在我们可以关注一下优化。现在我们的算法在计算每一个像素的阴影时,都需要对周围16个像素进行采样,这个采样数量有点惊人。回想之前我们使用硬件PCF优化采样数量的例子,这里是否也可以依靠硬件PCF来减少采样次数呢?
——还真可以,而且对性能的提升非常巨大。

举个例子,假设我们在渲染1080p的画面,那么原始的采样数量会是 1920 ∗ 1080 ∗ 16 = 33177600 1920*1080*16=33177600 1920108016=33177600次,而用上硬件PCF,我们知道一次最多可以采样4个像素,那么同时采样数量变成了 1920 ∗ 1080 ∗ 4 = 8294400 1920*1080*4=8294400 192010804=8294400次,性能直接暴涨4倍(实际上远远不止,如果算上卷积本身的计算量会提升更多)!

前面我们已经知道,硬件PCF可以采样2个像素的线性插值结果,如果我们能根据W调整插值位置,就可以用一次采样实现两次采样加权的效果,设一维卷积中,有权重 W x 、 W y 、 W z 、 W w W_x、W_y、W_z、W_w WxWyWzWw分别为像素x、y、z、w的Tent权重,四个像素的颜色分别为 a 、 b 、 c 、 d a、b、c、d abcd,则有:

C o l = W x a + W y b + W z c + W w d Col=W_xa+W_yb+W_zc+W_wd Col=Wxa+Wyb+Wzc+Wwd

使用2个PCF插值来模拟四个颜色的加权累加结果,设为c1和c2:

C o l = ( W x + W y ) ∗ c 1 + ( W z + W w ) ∗ c 2 Col = (W_x + W_y) * c_1 + (W_z + W_w) * c_2 Col=(Wx+Wy)c1+(Wz+Ww)c2

代入一维插值公式:

y = ( 1 − t ) a + t b , t ∈ [ 0 , 1 ] c 1 = ( 1 − t 1 ) ∗ a + t 1 ∗ b c 2 = ( 1 − t 2 ) ∗ c + t 2 ∗ d y=(1-t)a+tb, t \in [0, 1] \\ c_1=(1-t_1)*a+t_1*b \\ c_2=(1-t_2)*c+t_2*d y=(1t)a+tb,t[0,1]c1=(1t1)a+t1bc2=(1t2)c+t2d

得:
W x ∗ a + W y ∗ b = [ ( 1 − t 1 ) ∗ a + t 1 ∗ b ] ∗ ( W x + W y ) W z ∗ c + W w ∗ d = [ ( 1 − t 2 ) ∗ c + t 2 ∗ d ] ∗ ( W z + W w ) W_x*a+W_y*b=\left[(1-t_1)*a+t_1*b\right]*(W_x+W_y) \\ W_z*c+W_w*d=\left[(1-t_2)*c+t_2*d\right]*(W_z+W_w) Wxa+Wyb=[(1t1)a+t1b](Wx+Wy)Wzc+Wwd=[(1t2)c+t2d](Wz+Ww)

拿t1举例:
设 a = 0 ,代入: W x ∗ a + W y ∗ b = [ ( 1 − t 1 ) ∗ a + t 1 ∗ b ] ∗ ( W x + W y ) W y ∗ b = t 1 ∗ b ∗ ( W x + W y ) W y = t 1 ( W x + W y ) t 1 = W y W x + W y \begin{align*} 设a=0,代入: \\ W_x * a + W_y * b &= \left[(1-t_1)*a+t_1*b\right]*(W_x+W_y) \\ W_y * b &= t_1 * b * (W_x + W_y) \\ W_y &= t_1(W_x + W_y) \\ t_1 &= \frac{W_y}{W_x + W_y} \end{align*} a=0,代入:Wxa+WybWybWyt1=[(1t1)a+t1b](Wx+Wy)=t1b(Wx+Wy)=t1(Wx+Wy)=Wx+WyWy

同理

t 2 = W w W z + W w t2 = \frac{W_w}{W_z+W_w} t2=Wz+WwWw

我们以x、z像素中心分别作为左、右两个PCF采样的起点,y、w像素为采样终点,因为在纹素坐标系中1单位正好对应1纹素宽度,可以得出两个PCF采样坐标为:

C p 1 = C X + t 1 = C 0 − 1.5 + t 1 C p 2 = C Z + t 2 = C 0 + 0.5 + t 2 C_{p1} = C_X + t_1 = C_0 - 1.5 + t_1 \\ C_{p2} = C_Z + t_2 = C_0 + 0.5 + t_2 Cp1=CX+t1=C01.5+t1Cp2=CZ+t2=C0+0.5+t2

请添加图片描述

最后我们查看对比结果,阴影的边缘过渡变得柔和很多。

请添加图片描述

代码实现

首先是计算TentFilter的权重,U和V方向是一致的,共用一个函数:

// 宽度为3,高度为1.5的Tent核离散化权重计算
static float4 getTent3Weights(float kernelOffset) {
	// 方便计算的帮助变量
	float a = 0.5 - kernelOffset;
	float b = 0.5 + kernelOffset;
	float c = max(0, -kernelOffset);
	float d = max(0, kernelOffset);
	// 落在x上的面积
	float w1 = a * a * 0.5;
	// 落在y上的面积
	float w2 = (1 + a) * (1 + a) * 0.5 - 1 * w1 - c * c;
	// 落在z上的面积
	float w4 = b * b * 0.5;
	// 落在w上的面积
	float w3 = (1 + b) * (1 + b) * 0.5 - 1 * w4 - d * d;
	// 除以三角形面积,归一化
	return float4(w1 * 0.4444, w2 * 0.4444, w3 * 0.4444, w4 * 0.4444);
}

采样阴影强度的函数:

// 采样阴影强度
float pcfSample3x3Tent(float3 coord) {
	// 从UV坐标系转换到纹素坐标系
	float2 tentCenterInTexelSpace = coord.xy * _gShadowTexture_TexelSize.zw;
	// 找到最近的纹素中心,作为原点C0
	float2 centerOfFetchesInTexelSpace = floor(tentCenterInTexelSpace + 0.5);
	// 将采样坐标转换到C0坐标系,生成[-0.5,0.5)范围的局部坐标
	float2 offsetFromTentToCenterOfFetches = tentCenterInTexelSpace - centerOfFetchesInTexelSpace;

	// 从U方向计算权重
	float4 texelWeightsU = getTent3Weights(offsetFromTentToCenterOfFetches.x);
	// 从V方向计算权重
	float4 texelWeightsV = getTent3Weights(offsetFromTentToCenterOfFetches.y);
	
	//计算U,V各自的PCF权重(Wx+Wy)和(Wz+Ww)
	float2 fetchesWeightU = texelWeightsU.xz + texelWeightsU.yw;
	float2 fetchesWeightV = texelWeightsV.xz + texelWeightsV.yw;

	// 计算U,V各自的t1和t2,然后转换为PCF采样坐标
	float2 fetchesOffsetU = texelWeightsU.yw / fetchesWeightU.xy + float2(-1.5, 0.5);
	float2 fetchesOffsetV = texelWeightsV.yw / fetchesWeightV.xy + float2(-1.5, 0.5);

	//转换回UV坐标系
	fetchesOffsetU *= _gShadowTexture_TexelSize.xx;
	fetchesOffsetV *= _gShadowTexture_TexelSize.yy;

  // C0也转换回UV坐标系
	float2 bilinearFetchOrigin = centerOfFetchesInTexelSpace * _gShadowTexture_TexelSize.xy;
	float shadow = 1;
	// 求加权和,即为该坐标的最终阴影颜色
	shadow =  fetchesWeightU.x * fetchesWeightV.x * UNITY_SAMPLE_SHADOW(_gShadowTexture, float3(bilinearFetchOrigin + float2(fetchesOffsetU.x, fetchesOffsetV.x), coord.z));
	shadow += fetchesWeightU.y * fetchesWeightV.x * UNITY_SAMPLE_SHADOW(_gShadowTexture, float3(bilinearFetchOrigin + float2(fetchesOffsetU.y, fetchesOffsetV.x), coord.z));
	shadow += fetchesWeightU.x * fetchesWeightV.y * UNITY_SAMPLE_SHADOW(_gShadowTexture, float3(bilinearFetchOrigin + float2(fetchesOffsetU.x, fetchesOffsetV.y), coord.z));
	shadow += fetchesWeightU.y * fetchesWeightV.y * UNITY_SAMPLE_SHADOW(_gShadowTexture, float3(bilinearFetchOrigin + float2(fetchesOffsetU.y, fetchesOffsetV.y), coord.z));

	return shadow;
}

参考资料

  1. 九)unity自带的着色器源码剖析之——————UnityShadowLibrary.cginc文件分析(实时阴影和烘焙阴影、阴影淡化、阴影渗漏处理、PCF阴影过滤解决实时阴影锯齿)
  2. DirectX11 With Windows SDK–31 阴影映射
  3. 阴影的PCF采样优化算法
  4. 一篇文章为你讲透双线性插值
  5. Unity PCF 采样优化算法
  6. 【MLabRP】2.12:主光源的联级阴影-软阴影(PCF)
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐