目录
1. 概述
2. 示例
2.1. 着色器部分
2.1.1. 帧缓存着色器
2.1.2. 颜色缓存着色器
2.2. 绘制部分
2.2.1. 整体结构
2.2.2. 具体改动
3. 结果
4. 参考
1. 概述
所谓阴影, 就是物体在光照下向背光处投下影子的现象, 使用阴影技术能提升图形渲染的真实感. 实现阴影的思路很简单:
找出阴影的位置.
将阴影位置的图元调暗.
很明显, 关键还是在于如何去判断阴影的位置. 阴影检测的算法当然可以自己去实现, 但其实 OpenGL/webGL 已经隐含了这种算法: 假设摄像机在光源点, 视线方向与光线一致, 那么这个时候视图中看不到的地方肯定就是存在阴影的地方. 这实际上是由光源与物体之间的距离 (也就是光源坐标系下的深度 Z 值) 决定的, 深度较大的点为阴影点. 如下图所示, 同一条光线上的两个点 P1 和 P2,P2 的深度较大, 所以 P2 为阴影点:
图 1-1: 通过深度来判断阴影
当然, 在实际进行图形渲染的时候, 不会永远在光源处进行观察, 这个时候可以把光源点观察的结果保存下来 -- 使用上一篇教程《WebGL 简易教程(十三): 帧缓存对象(离屏渲染)》中介绍的帧缓冲对象(FBO), 将深度信息保存为纹理图像, 提供给实际图形渲染时判断阴影位置. 这张纹理图像就被称为阴影贴图(shadow map), 也就是生成阴影比较常用的 ShadowMap 算法.
2. 示例
在上一篇教程《WebGL 简易教程(十三): 帧缓存对象(离屏渲染)》中已经实现了帧缓冲对象的基本的框架, 这里根据 ShadowMap 算法的原理稍微改进下即可, 具体代码可参见文末的地址.
2.1. 着色器部分
同样的定义了两组着色器, 一组绘制在帧缓存, 一组绘制在颜色缓存. 在需要的时候对两者进行切换.
2.1.1. 帧缓存着色器
绘制帧缓存的着色器如下:
- // 顶点着色器程序 - 绘制到帧缓存
- var FRAME_VSHADER_SOURCE =
- 'attribute vec4 a_Position;\n' + // 位置
- 'attribute vec4 a_Color;\n' + // 颜色
- 'uniform mat4 u_MvpMatrix;\n' +
- 'varying vec4 v_Color;\n' +
- 'void main() {\n' +
- 'gl_Position = u_MvpMatrix * a_Position;\n' + // 设置顶点坐标
- 'v_Color = a_Color;\n' +
- '}\n';
- // 片元着色器程序 - 绘制到帧缓存
- var FRAME_FSHADER_SOURCE =
- 'precision mediump float;\n' +
- 'varying vec4 v_Color;\n' +
- 'void main() {\n' +
- 'const vec4 bitShift = vec4(1.0, 256.0, 256.0 * 256.0, 256.0 * 256.0 * 256.0);\n' +
- 'const vec4 bitMask = vec4(1.0/256.0, 1.0/256.0, 1.0/256.0, 0.0);\n' +
- 'vec4 rgbaDepth = fract(gl_FragCoord.z * bitShift);\n' + // Calculate the value stored into each byte
- 'rgbaDepth -= rgbaDepth.gbaa * bitMask;\n' + // Cut off the value which do not fit in 8 bits
- 'gl_FragColor = rgbaDepth;\n' + // 将深度保存在 FBO 中
- '}\n';
其中, 顶点着色器部分没有变化, 主要是根据 MVP 矩阵算出合适的顶点坐标; 在片元着色器中, 将渲染的深度值保存为片元颜色. 这个渲染的结果将作为纹理对象传递给颜色缓存的着色器.
这里片元着色器中的深度 rgbaDepth 还经过一段复杂的计算. 这其实是一个编码操作, 将 16 位的深度值 gl_FragCoord.z 编码为 4 个 8 位的 gl_FragColor, 从而进一步提升精度, 避免有的地方因为精度不够而产生马赫带现象.
2.1.2. 颜色缓存着色器
在颜色缓存中绘制的着色器代码如下:
- // 顶点着色器程序
- var VSHADER_SOURCE =
- 'attribute vec4 a_Position;\n' + // 位置
- 'attribute vec4 a_Color;\n' + // 颜色
- 'attribute vec4 a_Normal;\n' + // 法向量
- 'uniform mat4 u_MvpMatrix;\n' + // 界面绘制操作的 MVP 矩阵
- 'uniform mat4 u_MvpMatrixFromLight;\n' + // 光线方向的 MVP 矩阵
- 'varying vec4 v_PositionFromLight;\n' +
- 'varying vec4 v_Color;\n' +
- 'varying vec4 v_Normal;\n' +
- 'void main() {\n' +
- 'gl_Position = u_MvpMatrix * a_Position;\n' +
- 'v_PositionFromLight = u_MvpMatrixFromLight * a_Position;\n' +
- 'v_Color = a_Color;\n' +
- 'v_Normal = a_Normal;\n' +
- '}\n';
- // 片元着色器程序
- var FSHADER_SOURCE =
- '#ifdef GL_ES\n' +
- 'precision mediump float;\n' +
- '#endif\n' +
- 'uniform sampler2D u_Sampler;\n' + // 阴影贴图
- 'uniform vec3 u_DiffuseLight;\n' + // 漫反射光颜色
- 'uniform vec3 u_LightDirection;\n' + // 漫反射光的方向
- 'uniform vec3 u_AmbientLight;\n' + // 环境光颜色
- 'varying vec4 v_Color;\n' +
- 'varying vec4 v_Normal;\n' +
- 'varying vec4 v_PositionFromLight;\n' +
- 'float unpackDepth(const in vec4 rgbaDepth) {\n' +
- 'const vec4 bitShift = vec4(1.0, 1.0/256.0, 1.0/(256.0*256.0), 1.0/(256.0*256.0*256.0));\n' +
- 'float depth = dot(rgbaDepth, bitShift);\n' + // Use dot() since the calculations is same
- 'return depth;\n' +
- '}\n' +
- 'void main() {\n' +
- // 通过深度判断阴影
- 'vec3 shadowCoord = (v_PositionFromLight.xyz/v_PositionFromLight.w)/2.0 + 0.5;\n' +
- 'vec4 rgbaDepth = texture2D(u_Sampler, shadowCoord.xy);\n' +
- 'float depth = unpackDepth(rgbaDepth);\n' + // 将阴影贴图的 RGBA 解码成浮点型的深度值
- 'float visibility = (shadowCoord.z> depth + 0.0015) ? 0.7 : 1.0;\n' +
- // 获得反射光
- 'vec3 normal = normalize(v_Normal.xyz);\n' +
- 'float nDotL = max(dot(u_LightDirection, normal), 0.0);\n' + // 计算光线向量与法向量的点积
- 'vec3 diffuse = u_DiffuseLight * v_Color.rgb * nDotL;\n' + // 计算漫发射光的颜色
- 'vec3 ambient = u_AmbientLight * v_Color.rgb;\n' + // 计算环境光的颜色
- //'gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a);\n' +
- 'gl_FragColor = vec4((diffuse+ambient) * visibility, v_Color.a);\n' +
- '}\n';
这段着色器绘制代码在教程《WebGL 简易教程(十): 光照》绘制颜色和光照的基础之上加入可阴影的绘制. 顶点着色器中新加入了一个 uniform 变量 u_MvpMatrixFromLight, 这是在帧缓存中绘制的从光源处观察的 MVP 矩阵, 传入到顶点着色器中, 计算顶点在光源处观察的位置 v_PositionFromLight.
v_PositionFromLight 又传入到片元着色器, 变为该片元在光源坐标系下的坐标. 这个坐标每个分量都是 - 1 到 1 之间的值, 将其归一化到 0 到 1 之间, 赋值给变量 shadowCoord, 其 Z 分量 shadowCoord.z 就是从光源处观察时的深度了. 与此同时, 片元着色器接受了从帧缓冲对象传入的渲染结果 u_Sampler, 里面保存着帧缓冲对象的深度纹理. 从深度纹理从取出深度值为 rgbaDepth, 这是之前介绍过的编码值, 通过相应的解码函数 unpackDepth(), 解码成真正的深度 depth, 也就是在光源处观察的片元的深度. 比较该片元从光源处观察的深度 shadowCoord.z 与从光源处观察得到的同一片元位置的渲染深度 depth, 如果 shadowCoord.z 较大, 就说明为阴影位置.
注意这里比较时有个 0.0015 的容差, 因为编码解码的操作仍然有精度的限制.
2.2. 绘制部分
2.2.1. 整体结构
主要的绘制代码如下:
- // 绘制
- function DrawDEM(gl, canvas, fbo, frameProgram, drawProgram, terrain) {
- // 设置顶点位置
- var demBufferObject = initVertexBuffersForDrawDEM(gl, terrain);
- if (!demBufferObject) {
- console.log('Failed to set the positions of the vertices');
- return;
- }
- // 获取光线: 平行光
- var lightDirection = getLight();
- // 预先给着色器传递一些不变的量
- {
- // 使用帧缓冲区着色器
- gl.useProgram(frameProgram);
- // 设置在帧缓存中绘制的 MVP 矩阵
- var MvpMatrixFromLight = setFrameMVPMatrix(gl, terrain.sphere, lightDirection, frameProgram);
- // 使用颜色缓冲区着色器
- gl.useProgram(drawProgram);
- // 设置在颜色缓冲区中绘制时光线的 MVP 矩阵
- gl.uniformMatrix4fv(drawProgram.u_MvpMatrixFromLight, false, MvpMatrixFromLight.elements);
- // 设置光线的强度和方向
- gl.uniform3f(drawProgram.u_DiffuseLight, 1.0, 1.0, 1.0); // 设置漫反射光
- gl.uniform3fv(drawProgram.u_LightDirection, lightDirection.elements); // 设置光线方向(世界坐标系下的)
- gl.uniform3f(drawProgram.u_AmbientLight, 0.2, 0.2, 0.2); // 设置环境光
- // 将绘制在帧缓冲区的纹理传递给颜色缓冲区着色器的 0 号纹理单元
- gl.activeTexture(gl.TEXTURE0);
- gl.bindTexture(gl.TEXTURE_2D, fbo.texture);
- gl.uniform1i(drawProgram.u_Sampler, 0);
- gl.useProgram(null);
- }
- // 开始绘制
- var tick = function () {
- // 帧缓存绘制
- gl.bindFramebuffer(gl.FRAMEBUFFER, fbo); // 将绘制目标切换为帧缓冲区对象 FBO
- gl.viewport(0, 0, OFFSCREEN_WIDTH, OFFSCREEN_HEIGHT); // 为 FBO 设置一个视口
- gl.clearColor(0.2, 0.2, 0.4, 1.0); // Set clear color (the color is slightly changed)
- gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // Clear FBO
- gl.useProgram(frameProgram); // 准备生成纹理贴图
- // 分配缓冲区对象并开启连接
- initAttributeVariable(gl, frameProgram.a_Position, demBufferObject.vertexBuffer); // 顶点坐标
- initAttributeVariable(gl, frameProgram.a_Color, demBufferObject.colorBuffer); // 颜色
- // 分配索引并绘制
- gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, demBufferObject.indexBuffer);
- gl.drawElements(gl.TRIANGLES, demBufferObject.numIndices, demBufferObject.indexBuffer.type, 0);
- // 颜色缓存绘制
- gl.bindFramebuffer(gl.FRAMEBUFFER, null); // 将绘制目标切换为颜色缓冲区
- gl.viewport(0, 0, canvas.width, canvas.height); // 设置视口为当前画布的大小
- gl.clearColor(0.0, 0.0, 0.0, 1.0);
- gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); // Clear the color buffer
- gl.useProgram(drawProgram); // 准备进行绘制
- // 设置 MVP 矩阵
- setMVPMatrix(gl, canvas, terrain.sphere, lightDirection, drawProgram);
- // 分配缓冲区对象并开启连接
- initAttributeVariable(gl, drawProgram.a_Position, demBufferObject.vertexBuffer); // Vertex coordinates
- initAttributeVariable(gl, drawProgram.a_Color, demBufferObject.colorBuffer); // Texture coordinates
- initAttributeVariable(gl, drawProgram.a_Normal, demBufferObject.normalBuffer); // Texture coordinates
- // 分配索引并绘制
- gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, demBufferObject.indexBuffer);
- gl.drawElements(gl.TRIANGLES, demBufferObject.numIndices, demBufferObject.indexBuffer.type, 0);
- Windows.requestAnimationFrame(tick, canvas);
- };
- tick();
- }
这段代码的总体结构与上一篇的代码相比并没有太多的变化, 首先仍然是调用 initVertexBuffersForDrawDEM()初始化顶点数组, 只是根据需要调整了下顶点数据的内容. 然后传递非公用随帧不变的数据, 主要是帧缓存着色器中光源处观察的 MVP 矩阵, 颜色缓存着色器中光照的强度, 以及帧缓存对象中的纹理对象. 最后进行逐帧绘制: 将光源处观察的结果渲染到帧缓存; 利用帧缓存的结果绘制带阴影的结果到颜色缓存.
2.2.2. 具体改动
利用帧缓存绘制阴影的关键就在于绘制了两遍地形, 一个是关于当前视图观察下的绘制, 另一个是在光源处观察的绘制, 一定要确保两者的绘制都是正确的, 注意两者绘制时的 MVP 矩阵.
2.2.2.1. 获取平行光
这个实例模拟的是在太阳光也就是平行光下产生的阴影, 因此需要先获取平行光方向. 这里描述的是太阳高度角 30 度, 太阳方位角 315 度下的平行光方向:
- // 获取光线
- function getLight() {
- // 设置光线方向(世界坐标系下的)
- var solarAltitude = 30.0;
- var solarAzimuth = 315.0;
- var fAltitude = solarAltitude * Math.PI / 180; // 光源高度角
- var fAzimuth = solarAzimuth * Math.PI / 180; // 光源方位角
- var arrayvectorX = Math.cos(fAltitude) * Math.cos(fAzimuth);
- var arrayvectorY = Math.cos(fAltitude) * Math.sin(fAzimuth);
- var arrayvectorZ = Math.sin(fAltitude);
- var lightDirection = new Vector3([arrayvectorX, arrayvectorY, arrayvectorZ]);
- lightDirection.normalize(); // Normalize
- return lightDirection;
- }
2.2.2.2. 设置帧缓存的 MVP 矩阵
对于点光源光对物体产生阴影, 就像在点光源处用透视投影观察物体一样; 与此对应, 平行光对物体产生阴影就需要使用正射投影. 虽然平行光在设置 MVP 矩阵的时候没有具体的光源位置, 但其实只要确定其中一条光线就可以了. 在帧缓存中绘制的 MVP 矩阵如下:
- // 设置 MVP 矩阵
- function setFrameMVPMatrix(gl, sphere, lightDirection, frameProgram) {
- // 模型矩阵
- var modelMatrix = new Matrix4();
- //modelMatrix.scale(curScale, curScale, curScale);
- //modelMatrix.rotate(currentAngle[0], 1.0, 0.0, 0.0); // Rotation around x-axis
- //modelMatrix.rotate(currentAngle[1], 0.0, 1.0, 0.0); // Rotation around y-axis
- modelMatrix.translate(-sphere.centerX, -sphere.centerY, -sphere.centerZ);
- // 视图矩阵
- var viewMatrix = new Matrix4();
- var r = sphere.radius + 10;
- viewMatrix.lookAt(lightDirection.elements[0] * r, lightDirection.elements[1] * r, lightDirection.elements[2] * r, 0, 0, 0, 0, 1, 0);
- //viewMatrix.lookAt(0, 0, r, 0, 0, 0, 0, 1, 0);
- // 投影矩阵
- var projMatrix = new Matrix4();
- var diameter = sphere.radius * 2.1;
- var ratioWH = OFFSCREEN_WIDTH / OFFSCREEN_HEIGHT;
- var nearHeight = diameter;
- var nearWidth = nearHeight * ratioWH;
- projMatrix.setOrtho(-nearWidth / 2, nearWidth / 2, -nearHeight / 2, nearHeight / 2, 1, 10000);
- //MVP 矩阵
- var mvpMatrix = new Matrix4();
- mvpMatrix.set(projMatrix).multiply(viewMatrix).multiply(modelMatrix);
- // 将 MVP 矩阵传输到着色器的 uniform 变量 u_MvpMatrix
- gl.uniformMatrix4fv(frameProgram.u_MvpMatrix, false, mvpMatrix.elements);
- return mvpMatrix;
- }
这个 MVP 矩阵通过地形的包围球来设置, 确定一条对准包围球中心得平行光方向, 设置正射投影即可. 在教程《WebGL 简易教程(十二): 包围球与投影》中论述了这个问题.
2.2.2.3. 设置颜色缓存的 MVP 矩阵
设置实际绘制的 MVP 矩阵就恢复成使用透视投影了, 与之前的设置是一样的, 同样在教程《WebGL 简易教程(十二): 包围球与投影》中有论述:
- // 设置 MVP 矩阵
- function setMVPMatrix(gl, canvas, sphere, lightDirection, drawProgram) {
- // 模型矩阵
- var modelMatrix = new Matrix4();
- modelMatrix.scale(curScale, curScale, curScale);
- modelMatrix.rotate(currentAngle[0], 1.0, 0.0, 0.0); // Rotation around x-axis
- modelMatrix.rotate(currentAngle[1], 0.0, 1.0, 0.0); // Rotation around y-axis
- modelMatrix.translate(-sphere.centerX, -sphere.centerY, -sphere.centerZ);
- // 投影矩阵
- var fovy = 60;
- var projMatrix = new Matrix4();
- projMatrix.setPerspective(fovy, canvas.width / canvas.height, 1, 10000);
- // 计算 lookAt()函数初始视点的高度
- var angle = fovy / 2 * Math.PI / 180.0;
- var eyeHight = (sphere.radius * 2 * 1.1) / 2.0 / angle;
- // 视图矩阵
- var viewMatrix = new Matrix4(); // View matrix
- viewMatrix.lookAt(0, 0, eyeHight, 0, 0, 0, 0, 1, 0);
- /*
- // 视图矩阵
- var viewMatrix = new Matrix4();
- var r = sphere.radius + 10;
- viewMatrix.lookAt(lightDirection.elements[0] * r, lightDirection.elements[1] * r, lightDirection.elements[2] * r, 0, 0, 0, 0, 1, 0);
- // 投影矩阵
- var projMatrix = new Matrix4();
- var diameter = sphere.radius * 2.1;
- var ratioWH = canvas.width / canvas.height;
- var nearHeight = diameter;
- var nearWidth = nearHeight * ratioWH;
- projMatrix.setOrtho(-nearWidth / 2, nearWidth / 2, -nearHeight / 2, nearHeight / 2, 1, 10000);*/
- //MVP 矩阵
- var mvpMatrix = new Matrix4();
- mvpMatrix.set(projMatrix).multiply(viewMatrix).multiply(modelMatrix);
- // 将 MVP 矩阵传输到着色器的 uniform 变量 u_MvpMatrix
- gl.uniformMatrix4fv(drawProgram.u_MvpMatrix, false, mvpMatrix.elements);
- }
3. 结果
最后在浏览器运行的结果如下所示, 阴影存在于一些光照强度较暗的地方:
图 3-1: 地形的阴影
通过 ShadowMap 生成阴影并不是要自己去实现阴影检查算法, 更像是对图形变换, 帧缓冲对象, 着色器切换的基础知识的综合运用.
4. 参考
本文部分代码和插图来自《WebGL 编程指南》, 源代码链接: 地址 https://github.com/fafa1899/WebGLTutorial . 会在此共享目录中持续更新后续的内容.
来源: https://www.cnblogs.com/charlee44/p/12001631.html