1. 概述

所谓阴影,就是物体在光照下向背光处投下影子的现象,使用阴影技术能提升图形渲染的真实感。实现阴影的思路很简单:

  1. 找出阴影的位置。
  2. 将阴影位置的图元调暗。

很明显,关键还是在于如何去判断阴影的位置。阴影检测的算法当然可以自己去实现,但其实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); window.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编程指南》,源代码链接:地址 。会在此共享目录中持续更新后续的内容。

WebGL简易教程(十四):阴影的更多相关文章

  1. WebGL简易教程(十二):包围球与投影

    目录 1. 概述 2. 实现详解 3. 具体代码 4. 参考 1. 概述 在之前的教程中,都是通过物体的包围盒来设置模型视图投影矩阵(MVP矩阵),来确定物体合适的位置的.但是在很多情况下,使用包围盒 ...

  2. WebGL简易教程(十):光照

    目录 1. 概述 2. 原理 2.1. 光源类型 2.2. 反射类型 2.2.1. 环境反射(enviroment/ambient reflection) 2.2.2. 漫反射(diffuse ref ...

  3. WebGL简易教程(十五):加载gltf模型

    目录 1. 概述 2. 实例 2.1. 数据 2.2. 程序 2.2.1. 文件读取 2.2.2. glTF格式解析 2.2.3. 初始化顶点缓冲区 2.2.4. 其他 3. 结果 4. 参考 5. ...

  4. WebGL简易教程——目录

    目录 1. 绪论 2. 目录 3. 资源 1. 绪论 最近研究WebGL,看了<WebGL编程指南>这本书,结合自己的专业知识写的一系列教程.之前在看OpenGL/WebGL的时候总是感觉 ...

  5. WebGL简易教程(四):颜色

    目录 1. 概述 2. 示例:绘制三角形 1) 数据的组织 2) varying变量 3. 结果 4. 理解 1) 图形装配和光栅化 2) 内插过程 5. 参考 1. 概述 在上一篇教程<Web ...

  6. WebGL简易教程(九):综合实例:地形的绘制

    目录 1. 概述 2. 实例 2.1. TerrainViewer.html 2.2. TerrainViewer.js 3. 结果 4. 参考 1. 概述 在上一篇教程<WebGL简易教程(八 ...

  7. WebGL简易教程(十三):帧缓存对象(离屏渲染)

    目录 1. 概述 2. 示例 2.1. 着色器部分 2.2. 初始化/准备工作 2.2.1. 着色器切换 2.2.2. 帧缓冲区 2.3. 绘制函数 2.3.1. 初始化顶点数组 2.3.2. 传递非 ...

  8. 无废话ExtJs 入门教程十四[文本编辑器:Editor]

    无废话ExtJs 入门教程十四[文本编辑器:Editor] extjs技术交流,欢迎加群(201926085) ExtJs自带的编辑器没有图片上传的功能,大部分时候能够满足我们的需要. 但有时候这个功 ...

  9. Ocelot简易教程(四)之请求聚合以及服务发现

    上篇文章给大家讲解了Ocelot的一些特性并对路由进行了详细的介绍,今天呢就大家一起来学习下Ocelot的请求聚合以及服务发现功能.希望能对大家有所帮助. 作者:依乐祝 原文地址:https://ww ...

随机推荐

  1. Redis的使用--基本数据类型的操作命令和应用场景

    echo编辑整理,欢迎转载,转载请声明文章来源.欢迎添加echo微信(微信号:t2421499075)交流学习. 百战不败,依不自称常胜,百败不颓,依能奋力前行.--这才是真正的堪称强大!!! Red ...

  2. MIT线性代数:10.4个基本子空间

  3. [Java]Java类和对象内存分配详解

    描述 代码说明: 一.当Person p1 = new Person();第一次被调用时需要做两件事: 1.先判断类加载器是否加载过Person类,如果没有则加载到Person类型到方法区 2.在堆中 ...

  4. [考试反思]1008csp-s模拟测试65:突袭

    博客园挂了,不让粘图. 写的朴素一点. #1:100+100+25=225 #2:100+70+35=205 #2:100+60+45=205(我) 回到第一机房还算不错的第一仗. 考完之后我以为我A ...

  5. 你知道MySQL中的主从延迟吗?

    前言 在一个MySQL主备关系中,每个备库接受主库的binlog并执行. 正常情况下,只要主库执行更新生成所有的binlog,都可以传到备库并被正常的执行,这样备库就能够达到跟主库一样的状态,这就是最 ...

  6. Spring注解@Configuration是如何被处理的?

    从SpringApplication开始 一般情况下启动SpringBoot都是新建一个类包含main方法,然后使用SpringApplication.run来启动程序: @SpringBootApp ...

  7. 使用 Casbin 作为 ThinkPHP 的权限控制中间件

    PHP-Casbin 是一个强大的.高效的开源访问控制框架,它支持基于各种访问控制模型的权限管理. Think-Casbin 是一个专为 ThinkPHP5.1 定制的 Casbin 的扩展包,使开发 ...

  8. # & 等特殊字符会导致传参失败

    # & 等特殊字符会导致 post 传参失败 处理方法使用 encodeURIComponent 将字符串转化一下 实例 // toUpperCase() 转化为大写字母 var cateco ...

  9. Apache Hudi 介绍与应用

    Apache Hudi Apache Hudi 在基于 HDFS/S3 数据存储之上,提供了两种流原语: 插入更新 增量拉取 一般来说,我们会将大量数据存储到HDFS/S3,新数据增量写入,而旧数据鲜 ...

  10. 前端 vue单页面应用刷新网页后vuex的state数据丢失的解决方案(转载)

    最近接手了一个项目,前端后端都要做,之前一直在做服务端的语言.框架和环境,前端啥都不会啊. 突然需要前端编程,两天速成了JS和VUE框架,可惜还是个半吊子.然后遇到了一个困扰了一整天的问题.一直调试都 ...