投影(二)shadowMap

image.png
接上篇投影一

theme: smartblue

前文简单的介绍了影子的形成原理,确实过于简单了,实际上就是只写了一半,因为当时其实还没能完全理解,少了良好的实践, 现在补上。

简单回顾

之所以能把光源和相机统一起来,是因为不管是正交投影还是透视投影,都是基于光的粒子性,沿直线传播这一基本现象来的。

有影子的地方就是光源不能直接照射到的地方,把光源换成人眼,假如人眼能发出视线,人眼看不见的地方,即是视线照射不到的地方,最后把人眼换成相机。

如何判断一个点是否在光源(相机)的直视区域内呢? 假如可以直接从当前点P(三维空间中任意点),连上(点)光源的点,这样就有一条光线,我们只要判断这条光线是否会经过其他物体即可, 没有经过任何物体,即是没有遮挡。 但是这样做的消耗显然很大。 所以人们想出来投影贴图的办法。

投影贴图

投影贴图显然是一张图,这张图记录了一些信息。记录是深度信息,深度就是裁剪空间里的坐标z分量,z越大离屏幕越远。 开启深度测试后,绘制的结果只有最表面的片元了,也就是每个xy对应的最小的z, 最浅的深度。

简言之,开了深度测试之后,能让前面的物体正常遮挡后面的物体,最终绘制到画布上都是深度最浅的片元。 如果有片元的深度比这个深度还大,说明这个片元在它后面,被它遮挡,也就是在阴影区域内。

要拿到当前片元的深度信息很简单,有内置变量gl_FragCoord,这是一个四维向量,我们只需要取它的z分量即可。 片元着色器就修改如下,就这么简单,这样绘制结果的r分量就是对应xy的深度信息。

precision  mediump float ;
void main(){
    gl_FragColor.r = gl_FragCoord.z ;
}

不过直接这样存的话会丢失很多精度,因为最终输出到画布上的颜色,是一个四维向量 ,每个分量只有八位二进制的最大存储。因为按rgba色彩空间,确实是每一位只要256就够了。但是对于在裁剪空间中绘制的坐标来说,这个精度当然是远远不够的。

提高精度

我们输出的颜色也是四维向量,但是前面的代码只用到了一维,这次把其余三个维度都利用上。 这里就不按照十进制了,前面说了一个分量最大是256,指的是精度。 关于这个精度丢失问题,不明白且有兴趣的可以看看浮点数是什么或者JS的数字类型

precision  mediump float ;


const vec4 bitshift = vec4 ( 
    1.,
    256., 
    256. * 256. ,
    256. * 256. * 256.
);

const vec4 bitMask  = vec4(vec3(1./ 256.), 1.) ;


void main(){

    vec4 depth =  fract(gl_FragCoord.z* bitshift) ; // 因为最后输出到像素,限制小数位只有8 位二进制, 所以我们每个分量只存八位,  这样乘最后留下的分别是 整数部分,前八位二进制小数,
        //9 -16 位 的小数 16-24的小数 ,乘256就是把小数点往右移了八个二进制位。
        // 然后错位相减  
        depth -= depth.gbaa * bitMask; 
        
    gl_FragColor = depth ;

}

上面这个操作没看明白的,且看我用10进制演示一番。 下面这个就假定了每个分量的精度是1位十进制,最终每一个分量都存储了一位数,于是精度提升为4位十进制。

例如 n= 0.2345 ,n 乘 (1,10,100,1000),取小数部分 ,得到 (.2345,.345, .45, .5)
然后再乘(.1,.1,.1,0) 得到(.0345, .045, .05, .0) 相减 得到 (.2,.3,.4,.5)  ;

然后在比较深度的时候再还原回去,还原就很容易了,每一位乘以对应的数量级就可以了。

const vec4 bitshift = vec4 ( 
   1.,
   1./256., 
   1./(256. * 256.) ,
   1./(256. * 256. * 256.)
);
vec4 depth = texture2D(shadowMap,uv );

   float z= dot(bitshift, depth) ;
   

下图就是深度贴图的结果,之所以看到条纹,就是因为这里记录的是深度信息,深度相同,转化的颜色的值也相同,就是这么个效果。

image.png

image.png

帧缓冲区

投影贴图有了,但是需要在着色器里用它,肯定不能就这样直接绘制屏幕上,我们想要的是一个可以读取的纹理。 先不论webgl2的drawBuffers接口,看古老的方法。

用过three的估计也用过rendertarget, 渲染目标。 帧缓冲区就是一个渲染目标。 webgl把我们输入的顶点等数据处理之后会输出图像,这就是渲染,目标一般就是显示器,当目标不是显示器时,渲染的结果肯定就不会显示在屏幕上了。

帧缓冲区就是这样的存在,把渲染目标设置为缓冲区,渲染的结果就会在缓冲区中暂存起来,不会显示到屏幕上。 原生的方法就是直接绑定帧缓冲区, 解绑缓冲区,渲染目标就会回到默认的颜色缓冲区,从而能显示到屏幕上。 threejs就直接调用api了,解绑的时候同样把渲染目标设为null即可。

这样绘制顺序就是 先绑定帧缓冲区,使用绘制投影贴图的着色器,把投影贴图绘制到帧缓冲区里。

然后 ,解绑,让渲染目标回到默认值,使用真正要绘图的着色器, 在这个着色里进行深度比较,得出影子区域,绘制到屏幕上。

最终我们就看见了影子。

image.png

硬阴影

这种光影分明的绘制方法,被称作硬阴影。也就是影子的边界明晰,黑就是黑,白就是白。 确实有与之对应的软阴影,鄙人还没学会,等学会了补上。

下面来看具体实现。

绘制投影贴图的部分已经说的很清楚了,至少代码很清楚了。 剩下的就是阴影区域的判定问题。

需要注意的是,投影贴图是在光源相机下绘制的,而最终的图是另一个相机。 不同的相机就意味着投影和视图矩阵不同。 所以在这次绘制中,需要使用光源相机的矩阵才能得到当前片元在光源相机下的坐标,才能和投影贴图呼应上。

请看顶点着色器代码。 如果一个计算可以写在顶点着色器中就写在顶点着色中,这是为了减少计算量。 简单来说,就是片元着色器的执行次数是非常大的,而顶点着色器的执行次数,就是顶点数(索引数)。

lightClipPos就是当前顶点在光源相机下,最终在裁剪空间里的坐标。


attribute vec3 position  ;
attribute vec2 uv  ;
attribute vec3 normal  ;
uniform mat4 projectionMatrix  ;
uniform mat4 viewMatrix  ;
uniform mat4 modelMatrix  ;
uniform mat4 lightProjectionViewMatrix;
varying vec4 lightClipPos ;
varying vec3 v_normal  ;
varying vec3 v_position  ;
varying vec2 v_uv  ;
void main(){
    vec4 worldPos = modelMatrix *  vec4(position, 1.)  ;
    gl_Position = projectionMatrix * viewMatrix * worldPos;
    v_uv = uv ;
    v_normal =  mat3(modelMatrix)* normal ;
    v_position = worldPos.xyz; // 
    lightClipPos = lightProjectionViewMatrix *  worldPos  ;

}

再来看片元着色器,下面的代码并不完整,因为主要是为了说明isInShadow函数,无关代码基本上都剔除了。

经过顶点着色的计算,这里就直接获取了lightClipPos,目的是为了用它的xy分量作为uv坐标去投影贴图里取数据。因为我们用了四维矩阵,所以这里坐标是齐次坐标,首先就要去齐次,还原到三维向量坐标。 然后,因为裁剪空间的值域是[-1,1], 而uv是[0,1],所以还要除以2,加上0.5 。

取出深度信息后,乘上对应数量级还原。 理想情况下应是当前顶点在光源相机下的深度大于从投影贴图里存储的深度,就说明这个点被遮挡了,在阴影里。 但是实际上,会出现自遮挡问题,需要加一个偏移量,这个偏移量自然是越小越好。

自遮挡问题,说到底的话还是精度问题。虽然用上面的办法把精度提升到了32位,但是投影贴图这里还有问题,那就是它实际上是不连续的,一个像素是有大小的,但是有可能有两个坐标点处于同一像素中,那么他们在投影贴图中会对应同一个uv坐标,深度小就会把深度大遮挡了。投影贴图的尺寸分辨率越高,这个问题当然就越小, 另外就是光线方向越接近法线问题越小。

另外还有一个问题,那就是这个lightClipPos是我们直接用视图投影矩阵算出来的,实际上并没有经过显卡的裁剪,也就是说非可视区域的坐标可能也在里面,最终就会导致uv坐标超出01, 就像下面这样。 所以,可以看到代码理还有处理uv的。

image.png


precision  mediump float ;
uniform  sampler2D shadowMap;
uniform  vec3 lightPos ;  // 光源位置
uniform float intense ;  // 光强

uniform mat3 normalMatrix;
uniform mat4 viewMatrix;

varying vec4 lightClipPos ;

const vec4 bitshift = vec4 ( 
    1.,
    1./256., 
    1./(256. * 256.) ,
    1./(256. * 256. * 256.)
);
//  是当前点在相机裁剪空间的位置  不考虑视图矩阵
bool isInShadow(){ 
    //  因为要和之前的gl_fragCoord 对齐
    vec3 NDCpos =  (lightClipPos.xyz/lightClipPos.w )/2. + .5 ; //  这个要去齐次。 然后不管是uv 还是深度图的数据都是 01
    vec2 uv = NDCpos.xy;
    // if(uv.x >1.|| uv.y >1. || uv.x< 0.|| uv.y< 0. )return false;  // ndc的区间是01 按理说不可能超出才对 因为没有执行裁剪
    vec4 depth = texture2D(shadowMap,uv );

    float z= dot(bitshift, depth) ;//这里非得加上这么一个偏移量 不然就全黑 这是不是说明之前的记录还是有问题 不管怎么说这个偏移量越小越好,128是我调试的最小了
    // 原来自遮挡问题是存在的 ,加上这么一个偏移量就是为了解决它,但是正如偏移的名字,加上之后影子偏移了 , 只有在光线垂直平面时才几乎没有这个问题,之前的锥形灯就是这样的。
    return NDCpos.z  > z + 1./(256. * 128.);  // 如果当前深度比 之前贴图里的深度要大,那就是在阴影里
}

结尾

完整实现可以参考

https://gitee.com/withoutRock/draw/blob/master/my-code/webgl/games202/work0.js

https://gitee.com/withoutRock/draw/blob/master/my-code/webgl/缓冲帧/平行投影.html

202那个因为我暂时还实现不了模型文件的加载,所以直接用threejs的 rawShader了。

下面那个就是平行投影的实现,用了一些提前封装的东西。 对threeJS熟悉的看上面的大概会更容易理解。

至于软阴影,希望我能有写出来的那么一天吧。

© 版权声明
THE END
喜欢就支持一下吧
点赞6 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容