图形学的数学基础(三十七):阴影

982 阅读7分钟

本文已参与[新人创作礼]活动,一起开启掘金创作之路

转载请注明出处.

图形学的数学基础(三十七):阴影

All the variety, all the charm, all the beauty of life is made up of light and shadow
- Tolstoy

阴影对于真实感的渲染是极其重要的,因为它能够提供物体在空间中的相对位置关系,使得物体看起来不是漂浮在空中的。本章将重点介绍计算阴影的理论以及在光栅化中实时阴影算法。

1.jpg

《古墓丽影-暗影》 2018

之前介绍blinnPhong  Modelblinn-Phong\;Model光照模型是局部的,仅考虑光线 着色点 视线三个因素,不考虑其它物体对于当前着色点的影响,例如遮蔽,阴影等,而现实情况是,光照是及其复杂的,需要考虑周围物体对着色点的影响(间接光照),而在传统的局部着色(直接光照)中很难实现准确的表达,往往需要通过其它技术近似的模拟,今天介绍的shadow  Mappingshadow\;Mapping就是其中之一。一种在光栅化成像中实现阴影的技术。

Shadow  MappingShadow\;Mapping

一种图像空间(ImageSpaceImage-Space)算法。核心思想就是:如果场景中一个点不在阴影里,那么该着色点既可以被摄像机看到也可以被光源看到。如果一个着色点在阴影里,那么摄像机可以看到,光源是看不到的。

传统的Shadow  MappingShadow\;Mapping只能处理点光源,这样的阴影都有明显的边界和锯齿,一个着色点要么在阴影里,要么不在,缺少了中间柔和的过渡。这种阴影我们称之为硬阴影

实现方法

回顾zBufferz-Buffer算法,zBufferz-Buffer实际上是一张二维纹理,每一个纹素记录了距离相机最近的片元深度:

2.jpg

  • step1:那么自然的,我们可以先将摄像机移动到光源位置和方向,看向场景,渲染一张深度图,这张深度图就代表了哪些着色点可以被光源照亮,大于这个深度的片元都是没有光照的,这个阶段需要做两件事情,1:渲染深度图(Shadow  MappingShadow\;Mapping),2:记录相机变换到光源位置的变换矩阵MM

    3.png

    光照方向深度图

  • step2:将相机摆放到正常的观测方向和位置,对片元进行着色时,考虑每个片元是否有光照,方式是:用步骤1中存储的矩阵MM将当前着色点变换到光照空间,拿到在光照空间中当前着色点pp的深度,然后采样Shadow  MappingShadow\;Mapping中对应的当前点的最小深度,对比两者,如果着色点pp的深度大于纹理采样得到的深度,则认为当前片元无光照,需要在光照计算中加入shadow因子。

  • step3:通过以上两个步骤可以看出,一个pass无法完成shadow  Mapshadow\;Map的阴影渲染,需要两个pass,第一个pass负责渲染深度图(不渲染到屏幕,渲染到一张纹理中),第二个pass才是真正的对我们所看到场景的渲染,整体过程如下图所示:

    4.png

Shadow  MappingShadow\;Mapping存在的问题

阴影贴图的一个劣势是生成阴影的质量严重依赖于阴影图的分辨率和zBufferz-Buffer的浮点数精度,由于阴影图是在比较深度是进行采样的,因此算法容易出现混叠问题。一个常见的现象就是自遮挡(surface  acne  or  shadow  acnesurface\;acne\;or\;shadow\;acne):

5.jpg

造成这种奇怪现象的原因有两个。第一:受限于处理器浮点数精度。第二:阴影图受限于它的分辨率,阴影图中一个采样点的数值可能会覆盖离光源位置较远的多个片元。如下图很清晰的解释了分辨率造成shadow  acneshadow\;acne问题的原因:

6.jpg

上图中每个黄色的倾斜面板代表深度图中的单个纹素,几个片段对相同的深度样本进行了采样。正常情况下这是没问题的,但当光源以某个角度朝向平面时,问题就开始出现了,因为这个时候,我们生成的深度图也是从某个角度渲染的。进行深度比较时,一些片元将得到相同倾斜深度纹理像素,这样就造成了一部分片元深度高于当前纹素值(倾斜的黄色面包那),一些低于当前纹素值。形成了条纹状类似于摩尔纹的现象。

明白了原理后,问题解决就变得简单了,我们可以引入一个常数偏移量,一般称为Shadow  biasShadow\;bias,在进行深度比较时,从深度图采样得到的数值中加上这样一个偏移量,这样就可以避免倾斜面板(单个纹素)与共享同一纹素深度的几个片元形成区域的相交:

7.jpg

同样我们也可以发现,当光线与平面夹角越小,这种走样现象表现的越明显,因为夹角越小,同一纹素覆盖的像素范围更大。因此常数的Shadow  biasShadow\;bias是不可靠的,因为需要额外弥补的偏移量并不是一个常数,而是与光线入射角度相关的。更加通用的做法是,求着色点和和光线的点积,来实现动态的bias计算:

    float bias = max(0.05 * (1.0 - dot(normal, lightDir)), 0.005);

还没有完....引入Shadow  biasShadow\;bias会带来另外一个问题,当我们应用一个bias偏移到物体的实际深度后,如果偏差很大,则会看到阴影与实际物体位置偏差比较大:

8.jpg

这种现象俗称Peter  panningPeter\;panning:

9.jpeg

解决这个问题很简单,只需要在渲染阴影图时开启正面剔除即可。

    glCullFace(GL_FRONT);
    RenderSceneToDepthMap();
    glCullFace(GL_BACK);

硬阴影 vs 软阴影

10.jpg 11.jpg 明显可以看出软阴影没有明显的阴影边界,过渡自然,更符合自然中观察到的实际情况。而基于我们之前介绍的一系列方法是无法实现右侧图所示的软阴影的。

因为日常中我们所见到的绝大多数都是面光源,生成的阴影包含了两部分:UmbraPenumbraUmbra和Penumbra,软阴影其实就是在本影(UmbraUmbra)和没有阴影之间的区域有一个半影(UmbraPenumbraUmbra和Penumbra),产生了柔和过渡的效果:

12.jpg

为了实现软阴影,我们将介绍接下来的概念:PCFPCF

PCFPCF

前边一系列的操作都是为了解决Shadow  MappingShadow\;Mapping自遮挡的问题,但是边缘锯齿的问题仍然没有解决,本质上是因为深度图具有固定的分辨率,所以同一纹素通常会覆盖多个片元,多个片元从深度图中会提取到相同的深度值,得到相同的阴影判定,从而产生锯齿状的边缘。为了产生柔和的过渡边缘其中一个比较简单的实现就是PCFPCF,全称Percentagecloser  filteringPercentage-closer\;filtering. PCFPCF最初并不是用于实现软阴影的,而是为了使阴影边缘的抗锯齿。随后基于PCFPCFPCSSPCSS才是用于软阴影的。

基本思想很简单:原本我们在比较深度时,是基于当前着色点的深度和阴影图中采样得到的深度进行一次比对,PCFPCF的做法是,基于当前着色点的深度,对阴影图进行多次采样。每次采样后比对得出一个shadow因子,然后对多个shadow因子加权平均。有点类似于纹理贴图中抗锯齿的做法。本质上计算的是这个着色点与本影的接近程度(柔和过渡)。

    //5x5 PCF
    ivec2 ts = textureSize(depthSampler, 0);
    vec2 texelSize = vec2(1 / ts.x, 1 / ts.y);
    float shadow = 0.0;
    for(int x = -2; x <= 2; ++x) {
        for(int y = -2; y <= 2; ++y) {
            float pcfDepth = texture(depthSampler, projCoords.xy + vec2(x, y) * texelSize).r;
            shadow += currentDepth - bias  > pcfDepth? 1.0 : 0.0;
        }
    }
    shadow /= 25.0;

shadowMap部分实现代码

实现了Shadow  biasPCFShadow\;bias和PCFShadow  MapShadow\;Map fragmentShader代码:

fragmentShader

#version 300 es
precision highp float;

uniform sampler2D depthSampler;
uniform vec3 randomColor;
uniform vec3 lightDir;
uniform vec3 lightPos;

out vec4 outColor;

in vec4 lightSpacePosition;
in vec3 v_normal;
in vec3 model_Pos;

float shadowCalc(vec4 lightSpacePosition) {
    vec3 projCoords = lightSpacePosition.xyz / lightSpacePosition.w;
    projCoords = projCoords * 0.5 + 0.5;
    //shadow bias
    float bias = max(0.05 * (1.0 - dot(normalize(v_normal), lightDir)), 0.005);
    float currentDepth = projCoords.z;
    ivec2 ts = textureSize(depthSampler, 0);
    vec2 texelSize = vec2(1 / ts.x, 1 / ts.y);
    float shadow = 0.0;
    //PCF
    for(int x = -2; x <= 2; ++x) {
        for(int y = -2; y <= 2; ++y) {
            float pcfDepth = texture(depthSampler, projCoords.xy + vec2(x, y) * texelSize).r;
            shadow += currentDepth - bias  > pcfDepth? 1.0 : 0.0;
        }
    }
    shadow /= 25.0;
    if(projCoords.z > 1.0) {
        shadow = 0.0;
    }
    return shadow;
}

void main() {
    float shadow = shadowCalc(lightSpacePosition);
    shadow = min(shadow, 0.5);
    outColor = vec4(randomColor * (1.0 - shadow), 1.0);
}

参考

GAMES101 -现代计算机图形学入门-闫令琪

GAMES202 -高质量实时渲染

Real-Time Rendering

learningOpenGL