WebGL学习(十七)阴影

1. 简介

之前我们使用光照让物体表面有不同的颜色,看起来更加立体逼真。但是没有影子还是缺了点真实感,这里我们用到上一节的帧缓冲,通过阴影贴图shadow map或称深度贴图depth map技巧,实现阴影。

2. 原理

结合现实,阴影就是光线照不到的地方产生的一片区域

绘制一块阴影主要就是两个点阴影多大哪些区域算作阴影

阴影和透视投影一样,和原物体线性相关,光离得远阴影就小,近就大。

至于哪些区域算作阴影其实很简单:

WebGL学习(十七)阴影
假设有一点p1,经过透视投影到p2,如果p2的深度值小于p1,那么p2就在阴影中。

现在我们结合上面的几点总结一个画阴影的步骤:

  1. 我们将使用两个着色器。先将视点(相机)移动到光源的位置,并使用着色器1绘制原始物体,但是我们不需要真的画出来,我们只需要它的深度信息,所以使用帧缓冲绘制并获得阴影映射,存储在纹理中。
  2. 然后将视点移动到需要的位置,使用着色器2正常绘制正常视点的物体。取第一步视点下(form Light MVP*position)的某一点p坐标为(x, y),取阴影映射中对应位置的点位p2。只需要比较pp2的深度大小。如果p.z>p2.z(因为投影坐标系是左手)那么就可以说p在阴影中。然后把正常视点图像对应位置片元颜色改成暗一些,就是阴影啦。

3. 实现

如果没看懂原理,可以结合代码来看。

//...
// Lib是我简单封装的函数,本质就是和前面的操作一样,只是为了方便封装了一下
// 这里就是初始化了两个program,一个是绘制阴影映射、一个是正常绘制
// 有两套着色器代码shadowVertCode、shadowFragCode和vertexCode、fragmentCode
Lib.createProgram('shadow', shadowVertCode, shadowFragCode)
Lib.createProgram('normal', vertexCode, fragmentCode)
// 主程序
const lightDirection: vec3 = [ 0, 0, 5]
// 初始化帧缓冲
const OFFSCREEN_WIDTH = 4096
const OFFSCREEN_HEIGHT =4096
const framebuffer = gl.createFramebuffer();
// 绑定帧缓冲区
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
// 将纹理对象绑定到颜色缓冲附着点
const framebufferTexture = gl.createTexture()
gl.bindTexture(gl.TEXTURE_2D, framebufferTexture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, OFFSCREEN_WIDTH, OFFSCREEN_HEIGHT, 0, gl.RGBA, gl.UNSIGNED_BYTE, null);
gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, framebufferTexture, 0);
// 将渲染缓冲区绑定到深度缓冲附着点
const depthBuffer = gl.createRenderbuffer();
gl.bindRenderbuffer(gl.RENDERBUFFER, depthBuffer);
gl.renderbufferStorage(gl.RENDERBUFFER, gl.DEPTH_COMPONENT16, OFFSCREEN_WIDTH, OFFSCREEN_HEIGHT);
gl.framebufferRenderbuffer(gl.FRAMEBUFFER, gl.DEPTH_ATTACHMENT, gl.RENDERBUFFER, depthBuffer);
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.bindTexture(gl.TEXTURE_2D, null);
gl.bindRenderbuffer(gl.RENDERBUFFER, null);
///////////////////////////////////////////////////////////////////////////////
const draw = () => {
gl.bindFramebuffer(gl.FRAMEBUFFER, framebuffer);
gl.viewport(0, 0, OFFSCREEN_WIDTH, OFFSCREEN_HEIGHT);
// 在光源位置绘制图像,记录深度值
Lib.use('shadow')
// getMVP 会返回给定参数的mvp矩阵
const formLightPanelMvp = Lib.getMVP({  lookAt: {eye: lightDirection}, perspective: {fov: 50},model: {rotate: normalCubeRotate, translate: [0, 0, -1], scale: [2, 2, 1]}})
const formLightCubeMvp = Lib.getMVP({  lookAt: {eye: lightDirection},  perspective: {fov: 50},model: {rotate: normalCubeRotate, translate: [0, 0, 0]}})
drawPanel(formLightPanelMvp)
drawNormalCube(formLightCubeMvp, false)
// 切换成正常视点绘制
Lib.use('normal')
gl.bindFramebuffer(gl.FRAMEBUFFER, null);
gl.viewport(0, 0, gl.canvas.width, gl.canvas.height);
gl.bindTexture(gl.TEXTURE_2D, framebufferTexture);
const normalPanelMvp = Lib.getMVP({perspective: {fov: 10},model: {rotate: normalCubeRotate, translate: [0, 0, -1], scale: [2, 2, 1]}})
const normalCubeMvp = Lib.getMVP({perspective: {fov: 10},model: {rotate: normalCubeRotate, translate: [0, 0, 0]}})
Lib.setUniformMatrixValue('formLightMvpMat', formLightPanelMvp.mvpMat)
drawPanel(normalPanelMvp)
Lib.setUniformMatrixValue('formLightMvpMat', formLightCubeMvp.mvpMat)
drawNormalCube(normalCubeMvp, false)
}
draw()

3.1.绘制阴影纹理

我们先解释第一部分,绘制阴影纹理

3.1.1. 着色器代码

// 顶点着色器
// shadowVertCode 绘制阴影映射的顶点着色器
// mvp矩阵
uniform mat4 mvpMat;
// 顶点位置
attribute vec4 pos;
void main(){
// 绘制立方体顶点
gl_Position = mvpMat * pos;
}
// 片元着色器
// shadowFragCode 绘制阴影映射的片元着色器
precision mediump float;
void main(){
// 把深度值存在R分量中,其他值不要
// 深度值已经被归一化了
gl_FragColor = vec4(gl_FragCoord.z, 0, 0, 0);
}

着色器代码就不用说了,就是简单的绘制图像,不考虑光照什么的,因为我们只需要深度信息。

所以我们在片元着色器,通过gl_FragCoord获取了当前片元的深度值,并存在了颜色的r分量上。

3.1.1. 主程序代码

主程序中32~40行也没什么好说的,主要是设置mvp矩阵中的视点loolAt设置的是光线位置。

3.2.比较片元深度

3.2.1. 主程序代码

主程序就是使用阴影纹理,再次绘制图像。同时把物体在上一步的mvp矩阵传入。

3.2.2. 着色器代码

// 顶点着色器
// vertexCode
uniform mat4 mvpMat;
uniform mat4 modelMat;
attribute vec4 pos;
attribute vec4 color;
attribute vec4 originalNormal;
varying vec4 _color;
varying vec4 _originalNormal;
varying vec4 _vertexPosition;
uniform mat4 formLightMvpMat;
varying vec4 _fromLightPos;
void main(){
gl_Position = mvpMat * pos;
_vertexPosition = modelMat * pos;
_originalNormal = originalNormal;
_color = color;
// 计算视点在光源的坐标
_fromLightPos = formLightMvpMat * pos;
}
// 片元着色器
// fragmentCode
precision mediump float;
uniform vec3 lightColor;
uniform vec3 lightDirection;
uniform vec3 ambientColor;
uniform mat4 normalMat;
varying vec4 _color;
varying vec4 _originalNormal;
varying vec4 _vertexPosition;
// 纹理
uniform sampler2D shadowTexture;
varying vec4 _fromLightPos;
void main(){
vec3 vertexLightDirection = normalize(lightDirection - vec3(_vertexPosition));
vec3 normal = normalize(vec3(normalMat * _originalNormal));
float cos = max(dot(vertexLightDirection, normal), 0.0);
vec3 diffuse = lightColor * _color.rgb * cos;
vec3 ambient = ambientColor * _color.rgb;
vec4 objectFaceColor =  vec4(diffuse + ambient,  _color.a);
// 转化阴影坐标 -> 纹理坐标
vec3 shadowCoord = (_fromLightPos.xyz/_fromLightPos.w)/2.0 + 0.5;
// 获取阴影映射里对应位置的深度值
vec4 pixels = texture2D(shadowTexture, shadowCoord.xy); 
// 是否在阴影中
bool isInShadow = shadowCoord.z > pixels.r;
gl_FragColor = vec4(isInShadow ? vec3(0,0,0) : objectFaceColor.rgb,objectFaceColor.a);
}

主色器部分就复杂一些,这里请忽略没有注释的代码,那些是关于光照的一些代码,着重看的是阴影的处理。

首先在顶点着色器中通过formLightMvpMat计算出视点在光源位置的顶点坐标p1,然后传递给片元着色器。

然后,在片元着色器中获取阴影纹理中,对应坐标的纹素,也就是对应坐标点的像素p2

最后比较两者的深度值,如果p1.z > p2.z就说明p1p2后面,也就是处于阴影中注意投影空间是左手坐标系,z轴正方向朝屏幕里

这里还有几点细节需要补充:

p1p2看起来都是视点在光源的情况下的顶点坐标,那么他们两个的坐标应该是一样的呀,为什么能比较深度呢?

其实p1p2确实都是在formLightMvpMat变换下的顶点坐标,但是p2是从帧缓冲的纹理中取出的,这里面的片元经历了整个渲染流水线,被遮挡的片元已经被剔除了。

p1是在深度测试之前的坐标,即使是在看不见的地方,仍然可以获取到值。

因此这个比较实际是在确定哪些位置是第一次渲染中被隐藏的。

②:(_fromLightPos.xyz/_fromLightPos.w)/2.0 + 0.5;是什么意思

因为纹理坐标的范围是[0, 1]webgl坐标范围是[-1, 1],所以需要转化到一个范围才能比较。推导过程就不赘述了。

3.3. 效果

WebGL学习(十七)阴影

可以看到确实有一个阴影在背后的平面上。不过你肯定看到了图像上面有很多条纹,这个叫做马赫带(Mach band)

造成这种问题的原因,本质上就是计算精度的问题,因为阴影纹理存储的深度值pixels.r是8位的,而shadowCoord.zfloat类型它是16位的,所以当他们比较的时候总会有误差,即使是相同深度的两个像素,也可能误认为是在阴影中,也就会导致某些区域也成了黑色。

解决这个问题的方法很简单,就是加上一个很小的偏移量,抵消这部分误差。因为pixels.r8位比较小,误差比较大,所以在它这里加上一点偏量:

bool isInShadow = shadowCoord.z > pixels.r + 0.005;

WebGL学习(十七)阴影

4. 提高精度

如果我们将光源拉很远,就会发现阴影不见了:

WebGL学习(十七)阴影

这是因为,随着距离变远,物体之间的深度差距就变得很小,再加上计算机对于浮点数的精度问题,所以不足以区分深度关系,导致阴影区域判断失效。就是z-fighting

解决方法很简单,就是用vec4的所有分量一起存储深度值,由于webg1的着色器语言没有移位操作,所以只能采取乘除截取数据。

第一步就是修改阴影贴图的片元着色器:

// 片元着色器
precision mediump float;
void main(){
const vec4 bitShift = vec4(1.0, 256.0, 256.0 * 256.0, 256.0 * 256.0 * 256.0);
const vec4 bitMask = vec4(1.0/256.0, 1.0/256.0, 1.0/256.0, 0.0);
// fract作用是fract(x) = x - floor(x)
// 对于正数,就是取小数部分
vec4 rgbaDepth = fract(gl_FragCoord.z * bitShift);
rgbaDepth -= rgbaDepth.gbaa * bitMask;
gl_FragColor = rgbaDepth;
}

最后在绘制阴影的时候需要复原这个深度值:

WebGL学习(十七)阴影
这个公式正好就和点积一样,所以使用了dot

float unpackDepth(const in vec4 rgbaDepth) {
const vec4 bitShift = vec4(1.0, 1.0/256.0, 1.0/(256.0*256.0), 1.0/(256.0*256.0*256.0));
float depth = dot(rgbaDepth, bitShift);
return depth;
}
void main(){
// ....
vec4 pixels = texture2D(shadowTexture, shadowCoord.xy);
bool isInShadow = shadowCoord.z > unpackDepth(pixels) + 0.005;
// ....
}

上面的操作举个十进制的例子就好理解了:

// 假设深度值
z = 0.14235
// gl_FragCoord.z * bitShift之后每个分量
bitShift  0.14235, 1.4235,  14.235,  142.35
// fract函数之后
fract     0.14235  0.4235   0.235    0.35
// rgbaDepth.gbaa * bitMask
bitMask   0.04235  0.0235   0.035    0
// rgbaDepth -= rgbaDepth.gbaa * bitMask;
-         0.1      0.4      0.2      0.35
// 还原操作,乘以vec4(1, 1/10, 1/100, 1/1000)
0.1 + 0.04 + 0.002 + 0.00035 = 0.14235

1、gl_FragCoord.z * bitShift这句代码的意思就是移动小数点,因为就是由于小数精度不够(小数点位数不够),因为一个分量存不下。所以这里移动小数点后,每个分量存一部分小数点。

2、由于我们的深度值范围[0, 1]没有负数且几乎没有片元处在远裁剪面上(z=1时物体太远了可以视作看不见)。所以我们只需要小数部分。于是使用fract截取小数。

3、fract之后可以发现每个分量都有重复的小数部分0.2350.35,我们希望能减去重复的部分,又因为相邻分量的值位数正好差一个10,所以减去相邻部分就能得到唯一的小数部分了。

比如,r=0.14235g=0.4235,我们希望r存储第一部分0.1g存储第二部分0.4.所以我们可以r-g/10,这样r=0.1了。

4、减出来的结果实际上就是小数的每一部分,只不过都被放大了n*10。所以还原的时候只需要除回去就行了。

这里解释一下为什么代码里面使用256作为倍数,其实使用什么数值都可以,但是倍数越大后面几个分量分得的小数部分就越少,第一个分量会分得大部分小数,所以还是有可能导致精度不够的情况

// 100倍数
bitShift(1, 100, 100*100, 100*100*100)
// 假设深度值
z = 0.14235
bitShift  0.14235, 14.235,  142.35,  1423.5
fract     0.14235  0.235    0.35    0.5
bitMask   0.0235   0.035    0.05    0
-         0.11885  0.2      0.3      0.5
0.11885 + 0.02 + 0.003 + 0.0005 = 0.14235

可以发现第一个分量小数位数比10倍的时候更多了。所以这个倍数其实随便调整到合适就行。

还有一点注意,这个方法只适用于[0, 1]的数据,超过这个的数据算出来是无法还原的。

提高精度之后

WebGL学习(十七)阴影

5.扩展参考

ShadowMap及其延伸

Shadow Map阴影贴图小讲

learnopengl

原文链接:https://juejin.cn/post/7246672925666033721 作者:头上有煎饺

(0)
上一篇 2023年6月20日 上午11:18
下一篇 2023年6月21日 上午10:05

相关推荐

发表回复

登录后才能评论