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. NLP预训练模型-百度ERNIE2.0的效果到底有多好【附用户点评】

    ERNIE是百度自研的持续学习语义理解框架,该框架支持增量引入词汇(lexical).语法 (syntactic) .语义(semantic)等3个层次的自定义预训练任务,能够全面捕捉训练语料中的词法 ...

  2. Unity4-用户输入

    Input是一个类,可以接收用户的输入 使用AddComponentMenu("Demo1/InputTest1"),将脚本加入到工程中. //例子: void Update() ...

  3. Netty学习篇④-心跳机制及断线重连

    心跳检测 前言 客户端和服务端的连接属于socket连接,也属于长连接,往往会存在客户端在连接了服务端之后就没有任何操作了,但还是占用了一个连接:当越来越多类似的客户端出现就会浪费很多连接,netty ...

  4. P4544 [USACO10NOV]购买饲料Buying Feed

    额,直接思路就dp吧.(我还想了想最短路之类的233但事实证明不行2333.....) 直入主题: 化简题意:在x轴上有n个点,坐标为xi.从原点出发,目标点为e,在途中需要收集K重量的物品,在每个点 ...

  5. day 1 晚上 P2824 [HEOI2016/TJOI2016]排序 线段树

    #include<iostream> #include<cstdio> #include<cstdlib> #include<cmath> #inclu ...

  6. mysql找出重复数据的方法

    mysql找出重复数据的方法<pre>select openid,count(openid) from info group by openid,jichushezhi_id HAVING ...

  7. django 之创建自己的模板(使用案例)

    Django 创建自己的模板篇(实例) 此处需要创建模板,主要是对自己的模板进行扩展: 一般是扩展模板的tag和filter两个功能.可以用来创建你自己的tag和filter功能库. 创建模板库 分为 ...

  8. JDBC事务的简单使用

    在实际功能当中,经常会碰到同时对一组数据进行增加和减少,最常见的就是交易功能. 事务内执行的语句,要么都成功,要么都失败,如果有一句没执行成功,整个事务都不会提交的. import java.sql. ...

  9. nyoj 10 skiing (DFS)

    skiing 时间限制:3000 ms  |  内存限制:65535 KB 难度:5   描述 Michael喜欢滑雪百这并不奇怪, 因为滑雪的确很刺激.可是为了获得速度,滑的区域必须向下倾斜,而且当 ...

  10. hdu 1269 迷宫城堡 (tarjan)

    迷宫城堡Time Limit: 2000/1000 MS (Java/Others)    Memory Limit: 65536/32768 K (Java/Others)Total Submiss ...