技术美术百人计划 | 《4.3 实时阴影》笔记

一、基于图片的实时阴影技术

(一) 平面投影映射

1. 平面投影阴影

平面投影映射并不是一个基于图片的解决方案,但是思路值得借鉴。原理是根据光的方向,把物体的每个顶点投影到平面地面上。也就是当物体在光源和投影面之间,运用相似三角形原理,求得投影面上对应的点。

缺点:只能投影到平面(阴影的接收物只能是平面);投影物体必须在光线和平面之间。

如何解决?因此提出了投影阴影。

2. 投影阴影

投影阴影:把光源当做一个相机/投影器,然后将阴影投影渲染到一张纹理,最后渲染阴影接收者时,将上一步得到的阴影合并进去。

投影阴影在Unity中的实现:采用Projector组件

  • 第一步,设置Projector组件,通过它的参数使用给它的材质生成一个视锥体,Projector组件会使用赋给它的材质,绘制视锥体内所有物体。
  • 第二步,使用Render Texture生成一张纹理,将阴影绘制到纹理中。
  • 第三步,将设置Projector组件的物体和阴影纹理进行混合

(二) 阴影映射-Shadow Mapping

1. 前言

左图为正常摄像机视角,右图为从光源看向场景的角度渲出的一张图,一般叫做深度图,深度范围为0到1,离光源越近就越黑(接近0),越远就越白(接近1)。这张图反映出了场景中物体的远近关系。

在上图中的两个点位置如下,对于红点处于阴影之中,且在深度图中被遮挡,肯定不可见。这就是阴影映射的核心思想。

2. 原理

最常使用Shadow Map( 阴影映射纹理 )技术计算阴影。这种技术会首先把摄像机的位置放在与光源重合的位置上,那么场景中该光源的阴影区域就是那些摄像机看不到的地方。而Unity就是使用的这种技术 。

阴影映射纹理本质上也是一张深度图,它记录了从该光源的位置出发、能看到的场景中距离它最近的表面位置(深度信息)。

如何判定距离它最近的表面位置:

  • 把摄像机放置到光源的位置上
  • 调用LightMode=ShadowCaster的Pass(在shader或者fallback中寻找),来专门更新光源的阴影映射纹理。通过对顶点变换后得到光源空间下的位置,并据此来输出深度信息到阴影映射纹理中。

阴影映射的流程:

  1. 生成深度图:首先,从光源的位置生成一张深度图(shadowmap)。
  2. 深度测试:渲染物体。每次渲染时,需要和shadowmap的深度做比较(深度测试)如果一个片元的深度>它在shadowmap中的深度,那么就认为它在阴影中。

例如下图中红色标出的像素,它的深度是摄像机视角的深度,所以要先转换成阴影映射的坐标系,保证坐标系一致。

总结/注意事项:

  • ShadowMapping本质上是一种图像空间做法,也就是说生成shadow这一步不需要这个场景的几何信息
  • ShadowMapping只能做硬阴影
  • 软硬阴影的区别:硬阴影没有一个明显的从有阴影到没有的过度/界限(因为绝大多数的生活中的光源是面光源)

(三) 屏幕空间阴影映射-Screenspace Shadow Map

在Unity5中,Unity又使用了不同的阴影技术,即屏幕空间阴影映射技术(Screenspace ShadowMap)。

当使用了屏幕空间阴影映射技术时:

  • Unity 首先会通过调用LightMode=ShadowCaster 的 Pass 来得到光源的阴影映射纹理以及摄像机的深度纹理。(阴影图+深度图)
  • 如果摄像机深度纹理深度值>光源阴影映射纹理深度值,就说明该表面可见的,并处于该光源的阴影中。进而得到屏幕空间的阴影图。(比较)

通过这样的方式,阴影图就包含了屏幕空间中所有有阴影的区域。 如果我们想要一个物体接收来自其他物 体的阴影,只需要在Shader 中对阴影图进行采样。由于阴影图是屏幕空间下的,因此,我们首先需 要把表面坐标从模型空间变换到屏幕空间中,然后使用这个坐标对阴影图进行采样即可。

从FrameDebugger查看过程

二、阴影映射的优化

(一) 问题1:自阴影

1. 定义

自阴影:因为shadow mapping的分辨率有限,离散的采样点以及数值上的偏差可能造成不正确的自阴影效果。也被称为Z-Fighting或者阴影粉刺(Surface Acne)。

出现自阴影的原因:如下图所示,在映射shadowmap的过程中,下边一大个范围内的阴影值都是一个值(shadowmap的一格)

但是对于摄像机来说,深度值不一样

那么我们取一个后面部分“被迫涵盖的”蓝色着色点来看:从摄像机视角记录的场景中的深度值记为zp,但它采样shadowmap得到的值zs由于被迫一视同仁而直接取了红色着色点的深度值,于是zp>zs,蓝色点就被列入了阴影中。

2. 如何避免

如何避免自阴影:

  1. 即从shadowmap本身的采样分辨率出发的——提高shadowmap的分辨率(直接体现为texel尺寸变小,即上面图中的红色线变短),我们随便从Unity中搭一个Cube,看看选择不同shadowmap分辨率的效果,Unity提供的最低分辨率和最高分辨率:


  1. 深度偏移(Depth Bias),增加深度偏移会使该像素向光源靠近。

红色横线的阴影值都是一样的,那么把红点的深度值加上深度偏移,那么它和下半部分其他点的深度是一样的了,这样蓝点就不算在阴影中了。

但是对于上半部分来说,如果偏移值太大,会产生脱节的现象(Peter Panning):

计算:


  1. 法线偏移(Normal Bias),沿着表面法线方向向外偏移


3. Unity中实现自阴影的优化

思路:

(二) 问题2:走样

走样,最明显的表现就是锯齿,我们可以看下边的例图

在什么阶段会走样?

  • 初始采样:渲染shadowmap时
  • 重采样:从相机位置对shadowmap进行重采样时

1. 初始采样阶段

shadowmap在世界空间均匀分配(左图的三段纹素对应的世界空间的一个像素大小是一样的)。经过透视投影后,根据近大远小的原理,原来大小不一样的近平面和远平面,在屏幕内占的像素便一样了。

这时远平面对应在shadowmap中的纹素就比近平面大了,这种离相机近的部分走样的情况一般称为透视误差

如何解决

  • 思路1:因为我们在使用shadowmap时,相机是经过透视投影的,但生成shadowmap时并没有经过透视投影,所以我们在生成shadowmap时就进行一次透视投影。
  • 思路2:尽量减少近平面和远平面之间的像素差距,Unity中的级联阴影映射就是用的这个思路。
一、级联阴影映射(Cascade Shadowmap)-划分视锥体

下图将视锥体分为两部分,每一部分都对应一张阴影纹理,同时每个纹理的分辨率是一样的。

下图的设置Shadow Cascades就是视锥体分割的数量


2. 重采样阶段

shadowmap可以理解为一张动态生成的纹理

重采样误差的解决方法:滤波(Filter)

  • 滤波:在图像处理中,通过滤波强调一些特征或者去除图像中一些不需要的部分
  • 滤波(高通滤波,低通滤波等等)是一个领域操作算子,利用给定像素周围的像素的值决定该像素的最终输出值

阴影滤波:使用一部分shadowmap采样点来计算某个指定View采样点的最终阴影结果的方法。

一、PCF滤波-模糊阴影边缘

Percentage Closer Filtering (PCF)

PCF滤波是阴影映射的一种滤波方式。下图为它的流程图,其中前两步和阴影映射是一样的。

第三步是核心:

在第二幅图的深度测试通过的点,也就是着色像素点,取它周围一个Filter大小的区域(滤波核)的像素点(图三为7x7),核的中心对准实际要计算阴影的片元,核的其它区域对准其周围的片元。然后为核上的 49个区域标记上权重,并且这些权重的和为 1。接着对核上的 49 个 区域进行单独的阴影计算。

  • 当小于图2时,就认为它在阴影里,记为0,设置为blocked,
  • 大于图2时,不在阴影里,记为1,设置为visable。

最后,让每个结果乘上其对应的权重,即可得到一个 [0, 1] 范围内的小数,我们可以把这个数当作这一点的可见性。(图中每个像素权重都为1/49)

在为片元着色的时候,就可以用这个数值来进行插值,而不是原本的非 0 即 1,得到该像素点的颜色输出值。

可以看出,这其实就是一个卷积核形式的模糊,不过这里模糊的是目标片元与其周片元的阴影比较结果,这样,处于阴影中心的片元,得到的结果就会更趋近于 1,处于阴影边缘的片元,得到的结果就会更趋近于 0,这样就实现了阴影边缘的渐变模糊效果。

采样数K(其实就是Filter的大小)

  • 可以是规则滤波,3*3或者5*5等
  • 也可以采用泊松滤波(Poisson Disk)的形式来分布一定数量的采样点
  • 3x3的PCF滤波示例:

float visibility_PCF(sampler2D shadowMap, vec4 coords) 
{
  const float bias = 0.005;
  float sum = 0.0;
  
  // 初始化泊松分布
  poissonDiskSamples(coords.xy);
  
  // 采样
  for(int i = 0;i<NUM_SAMPLES;++i)
  {
    float depthInShadowmap = unpack(texture2D(shadowMap,coords.xy+disk[i]*0.001).rgba);
    sum += ((depthInShadowmap + bias)< coords.z?0.0:1.0);
  }
  
  // 返还平均采样结果
  return sum/float(NUM_SAMPLES);
}
二、PCSS滤波

Percentage Closer Soft Shadows(PCSS)

参考:实时阴影技术(1)Shadow Mapping - KillerAery - 博客园 (cnblogs.com)

(一) 提出原因

Shadow Mapping 还存在硬阴影(Hard Shadow)的问题,因为现实世界的影子往往是软阴影(Soft Shadow)。一个现实观察是,当投影物与阴影之间的距离越远,则阴影越软,如下图:笔尖阴影由于与笔尖的距离较近,因此阴影边缘较为锐利;而远处笔身阴影则因与笔身距离较远,阴影边缘较为发散且模糊。

这是因为较大的光源面会有一些区域被遮蔽一部分光又接受一部分光,从而产生半影(Penumbra),直观看就是没那么暗的边缘处模糊阴影。


Penumbra Size半影大小

用二维平面的图去描述,实际上就是光源段

两端与遮挡物连直线后打在被投影物上的即是 半影段

,也就是说这段半影需要有渐变的阴影效果。假如我们用 PCF 算法中的圆盘半径大小等同于这个半影段的尺寸

,就能实现这段的渐变阴影效果(可以想想为什么)。

现在,由下图的几何关系容易推出:

其中,

是光源面积尺寸,

是遮挡物的深度,

是被投影物的深度(实际上就是 z')。

但是 PCF 算法的圆盘半径大小是固定的,因此处处的边缘看起来都带有相同的渐变范围,这和我们看到的笔尖阴影现象不符合(近处模糊应该更少些,远处模糊应该多些),所以我们可以只要根据不同位置动态地修改圆盘半径大小(实际上就是动态地计算

),这个也就是PCSS的核心部分。

(二) Blocker Search

我们不能简单把一个投影点变换成Shadow Map的坐标后,直接拿单个坐标采样 ShadowMap 的深度来作为

。这是因为投影点的单次采样实际上就是单一直线连向了光源面的中心,而这条直线要是没有碰到遮挡物(即

),从而得出该投影点为全亮的结论。

但实际很多场景中,投影点和光源面处处连线后会发现有相当一部分光线会碰到遮挡物(如下图),因此该投影点应该属于半影范围内。

为此,我们可以对 ShadowMap 的一定范围内进行多重采样,每次采样得到的深度若小于

,则认为遇到遮挡物,并算入平均遮挡深度的贡献,这样多重采样之后得到的平均遮挡深度就作为

如何确定采样的范围半径呢?两个参数决定:

的尺寸、投影点与光源的距离

这样,计算 Blocker 平均遮挡深度的整个过程为:

float findBlocker( sampler2D shadowMap, vec2 uv, float zReceiver ) 
{
  float dBlocker = zReceiver * 0.01;
  const float wLight = 0.006;
  const float c = 100.0;	
  float wBlockerSearch = wLight * zReceiver * c;//采样半径
  
  float sum = 0.01;	// 取0.01一是为了避免出现0除问题,二是当多重采样没有贡献时的dBlocker/sum将等于zReceiver
  for(int i = 0;i<BLOCKER_SEARCH_NUM_SAMPLES;++i)
  {
    float depthInShadowmap = unpack(texture2D(shadowMap, uv + disk[i] * wBlockerSearch).rgba);
    if(depthInShadowmap < zReceiver)//有遮挡物
    {
      dBlocker += depthInShadowmap;
      sum += 1.0;
    }
  }
  return dBlocker/float(sum);
}
(三) PCSS 算法过程
  1. Blocker Search:通过多重采样,计算出平均遮挡深度

  2. Penumbra Size:计算圆盘半径大小

  1. Filtering:通过多重采样,计算出平均 Visibility(实际上就是调用PCF算法)
float visibility_PCSS(sampler2D shadowMap, vec4 coords)
{
  poissonDiskSamples(coords.xy);
  
  // STEP 1: avgblocker depth
  float dBlocker = findBlocker(shadowMap,coords.xy,coords.z);
  
  // STEP 2: penumbra size
  const float wLight = 0.006;
  float wPenumbra = (coords.z-dBlocker)/dBlocker * wLight;
  
  // STEP 3: filtering
  const float bias = 0.005;
  float sum = 0.0;
  for(int i = 0;i<PCF_NUM_SAMPLES;++i)
  {
    float depthInShadowmap = unpack(texture2D(shadowMap, coords.xy + disk[i] * wPenumbra).rgba);
    sum += ((depthInShadowmap + bias)< coords.z?0.0:1.0);
  }
  return sum/float(PCF_NUM_SAMPLES);
}

PCF算法效果图:

PCSS算法效果图:

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值