WebGL简易教程(十四):阴影

目录

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编程指南》,源代码链接:地址 。会在此共享目录中持续更新后续的内容。