目录javascript
所谓阴影,就是物体在光照下向背光处投下影子的现象,使用阴影技术能提高图形渲染的真实感。实现阴影的思路很简单:html
很明显,关键仍是在于如何去判断阴影的位置。阴影检测的算法固然能够本身去实现,但其实OpenGL/WebGL已经隐含了这种算法:假设摄像机在光源点,视线方向与光线一致,那么这个时候视图中看不到的地方确定就是存在阴影的地方。这其实是由光源与物体之间的距离(也就是光源坐标系下的深度Z值)决定的,深度较大的点为阴影点。以下图所示,同一条光线上的两个点P1和P2,P2的深度较大,因此P2为阴影点:java
固然,在实际进行图形渲染的时候,不会永远在光源处进行观察,这个时候能够把光源点观察的结果保存下来——使用上一篇教程《WebGL简易教程(十三):帧缓存对象(离屏渲染)》中介绍的帧缓冲对象(FBO),将深度信息保存为纹理图像,提供给实际图形渲染时判断阴影位置。这张纹理图像就被称为阴影贴图(shadow map),也就是生成阴影比较经常使用的ShadowMap算法。git
在上一篇教程《WebGL简易教程(十三):帧缓存对象(离屏渲染)》中已经实现了帧缓冲对象的基本的框架,这里根据ShadowMap算法的原理稍微改进下便可,具体代码可参见文末的地址。github
一样的定义了两组着色器,一组绘制在帧缓存,一组绘制在颜色缓存。在须要的时候对二者进行切换。算法
绘制帧缓存的着色器以下:编程
// 顶点着色器程序-绘制到帧缓存 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矩阵算出合适的顶点坐标;在片元着色器中,将渲染的深度值保存为片元颜色。这个渲染的结果将做为纹理对象传递给颜色缓存的着色器。canvas
这里片元着色器中的深度rgbaDepth还通过一段复杂的计算。这实际上是一个编码操做,将16位的深度值gl_FragCoord.z编码为4个8位的gl_FragColor,从而进一步提高精度,避免有的地方由于精度不够而产生马赫带现象。数组
在颜色缓存中绘制的着色器代码以下:浏览器
// 顶点着色器程序 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的容差,由于编码解码的操做仍然有精度的限制。
主要的绘制代码以下:
//绘制 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矩阵,颜色缓存着色器中光照的强度,以及帧缓存对象中的纹理对象。最后进行逐帧绘制:将光源处观察的结果渲染到帧缓存;利用帧缓存的结果绘制带阴影的结果到颜色缓存。
利用帧缓存绘制阴影的关键就在于绘制了两遍地形,一个是关于当前视图观察下的绘制,另外一个是在光源处观察的绘制,必定要确保二者的绘制都是正确的,注意二者绘制时的MVP矩阵。
这个实例模拟的是在太阳光也就是平行光下产生的阴影,所以须要先获取平行光方向。这里描述的是太阳高度角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; }
对于点光源光对物体产生阴影,就像在点光源处用透视投影观察物体同样;与此对应,平行光对物体产生阴影就须要使用正射投影。虽然平行光在设置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简易教程(十二):包围球与投影》中论述了这个问题。
设置实际绘制的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); }
最后在浏览器运行的结果以下所示,阴影存在于一些光照强度较暗的地方:
经过ShadowMap生成阴影并非要本身去实现阴影检查算法,更像是对图形变换、帧缓冲对象、着色器切换的基础知识的综合运用。
本文部分代码和插图来自《WebGL编程指南》,源代码连接:地址 。会在此共享目录中持续更新后续的内容。