【笔记】《WebGL编程指南》学习(10)

WebGL编程指南学习(10)

This is just the end of the beginning. —— Winston Curchill

10. 高级技巧(又续)

绘制阴影

实现阴影有若干种不同的方法,书里的例子是阴影贴图(Shadow map)或称深度贴图(Depth map)

如何实现阴影?

  • 物理上:对一根光线上的两个点,如果一个点的z值大于另一个点,则它在阴影中
  • 算法上:
    • 计算光源到物体的距离
    • 根据计算出的结果,绘制场景
  • 计算机实现上:
    • 一组着色器用来计算光源到物体的距离,得到一张纹理图像(阴影贴图),并将结果传入另一组串色器
      • 将视点移到光源位置处,运行着色器。这时记录可见的片元的z值,并写入阴影贴图中
      • 这里使用帧缓冲区对象记录片元到光源的距离
    • 第二组着色器通过阴影贴图实现阴影
      • 将视点移回原来的位置,绘制场景。此时,计算每个原片在光源坐标系下的坐标,并与阴影贴图中记录的z值比较,如果前者大于后者,说明当前片元处在阴影中,用较深暗的颜色绘制
  • 这里用到了帧缓冲区和切换着色器技术
// 第一组着色器:生成阴影贴图
// 顶点缓冲区
var SHADOW_VSHADER_SOURCE = 
    //...
    'void main() {\n' +
    '	gl_Position = u_MvpMatrix * a_Position;\n' +
    '}\n';
// z值缓冲区
var SHADOW_FSHADER_SOURCE = 
    //...
    'void main() {\n}' +
    '	gl_FragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 0.0);\n' +
    '}\n';
// 第二组着色器:正常绘制
var VSHADER_SOURCE = 
    //...
    'void main() {\n' +
    '	gl_Position = u_MvpMatrix * a_Position;\n' + 
    '	v_PositionFromLight = u_MvpMatrixFromLight * a_Position;\n' +
    //...
var FSHADER_SOURCE = 
  	//...
  	'uniform sampler2D u_ShadowMap;\n' +
  	//...
  	'void main() {\n' +
  	'	vec3 shadowCoord = (v_PositionFromLight.xyz/v_PositionFromLight.w)/2.0 + 0.5;\n' +
  	'	vec4 rgbaDepth = texture2D(u_ShadowMap, shadowCoord.xy);\n' +
  	'	float depth = rgbaDepth.r;\n' +
  	'	float visibility = (shadowCoord.z > depth + 0.005) ? 0.7 : 1.0;\n' +
  	'	gl_FragColor = vec4(v_Color.rgb * visibility, v_Color.a);\n' +
  	'}\n';
第一组着色器
  • 将绘制目标切换到帧缓冲区对象,把视点在光源处的MVP矩阵传给u_MvpMatrix
  • 片元着色器把片元的z值写入了纹理贴图
gl_FragColor = vec4(gl_FragCoord.z, 0.0, 0.0, 0.0);
  • 绘制结果是个纹理对象,要传给另一对着色器(Javascript完成)
第二组着色器
  • 这里的u_MvpMatrix是视点在远处的MVP矩阵,而视点位于光源处的MVP矩阵是u_MvpMatrixFromLight
  • z值的计算方法就是:
gl_Position.z/gl_Position.w/2.0+0.5;

这个计算方法就是默认的gl_FragCoord.z。只不过这里gl_FragCoord不是要求的,所以得显示写出这个计算过程

这里恰好,xy坐标的归一化方式和纹理坐标归一化一致,所以一行代码算出的就是纹理上的坐标

  • 从阴影贴图中抽取纹素,然后比较depth和shadowCoord.z
  • 比较的时候添加了一个0.005的偏移量,目的是避免马赫带效应(计算误差)
JavaScript部分
var OFFSCREEN_WIDTH = 1024, OFFSCREEN_HEIGHT = 1024; // 渲染阴影贴图的纹理大小
var LIGHT_X = 0, LIGHT_Y = 7, LIGHT_Z = 2; // 光源位置

function main() {
  //...
  // 第一组着色器
  var shadowProgram = createProgram(gl, SHADOW_VSHADER_SOURCE, SHADOW_FSHADER_SOURCE);
  // 第二组着色器
  var normalProgram = createProgram(gl, VSHADER_SOURCE, FSHADER_SOURCE);
  //...
  // 设置顶点信息
  var triangle = initVertexBuffersForTriangle(gl);
  var plane = initVertexBufferForPlane(gl);
  //...
  // 初始化帧缓冲区(FBO)
  var fbo = initFrameBufferObject(gl);
  //...
  gl.activeTexture(gl.TEXTURE0);
  gl.bindTexture(gl.TEXTURE_2D, fbo.texture) // 把纹理对象挂到FBO的texture上
  //...
  // 计算视点到光源的MVP,为渲染阴影贴图准备
  var viewProjMatrixFromLight = new Matrix4();
  viewProjMatrixFromLight.setPerspective(70.0, OFF_SCREEN_WIDTH/OFF_SCREEN_HEIGHT, 1.0, 100.0);
  viewProjMatrixFromLight.lookAt(LIGHT_X, LIGHT_Y, LIGHT_Z, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);
  // 设置正常视点的MVP,为正常渲染准备
  var viewProjMatrix = new Matrix4();
  viewProjMatrix.setPerspective(45, canvas.width/canvas.height, 1.0, 100.0);
  viewProjMatrix.lookAt(0.0, 7.0, 9.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);
  // Bonus: 额外任务,让三角形旋转起来!
  var currentAngle = 0.0; // 当前旋转角度
  var mvpMatrixFromLight_t = new Matrix4(); // 三角形的MVP
  var mvpMatrixFromLight_p = new Matirx4(); // 平面的MVP
  var tick = function(){
    currentAngle = animate(currentAngle);
    // 将渲染对象切换为FBO
    gl.bindFrameBuffer(gl.FRAMEBUFFER, fbo);
    //...
    gl.useProgram(shadowProgram);
    // 渲染以生成纹理贴图
    drawTriangle(gl, shadowProgram, triangle, currentAngle, viewProjMatrixFromLight);
    mvpMatrixFromLight_t.set(g_mvpMatrix);
    drawPlane(gl, shadowProgram, plane, viewProjMatrixFromLight);
    mvpMatrixFromLight_p.set(g_mvpMatrix);
    
    // 正常渲染
    gl.bindFrameBuffer(gl.FRAMEBUFFER, null);
    gl.useProgram(normalProgram);
    gl.uniform1i(normalProgram.u_ShadowMap, 0); // 传递gl.TEXTURE0
    gl.uniformMatrix4fv(normalProgram.u_MvpMatrixFromLight, false, mvpMatirxFromLighjt_t.elements);
    drawTriangle(gl, normalProgram, triangle, currentAngle, viewProjMatrix);
    gl.uniformMatrix4fv(normalProgram.u_MvpMatrixFromLight, false, mvpMatirxFromLighjt_p.elements);
    drawTriangle(gl, normalProgram, plane, currentAngle, viewProjMatrix);
    
    window.requestAnimationFrame(tick, canvas);
  };
  tick();
}

提高精度

如果光源与照射物体变远,gl_FragCoord.z的值也会增大。当光源足够远时,gl_FragCoord.z大到无法存储在只有8位的R分量中了。

咋办呢?

使用阴影贴图的RGBA4个分量,共32位来存储z值。

pack压缩

把gl_FragCoord.z拆为4个字节RGBA,每个字节的精度是1/256,所以把大于1/256的部分存在R中,1/256到1/(256*256)的部分存在G中,以此类推。使用内置函数fract()计算上述分量的值。fract()的作用是舍弃整数部分,返回小数部分.

const vec4 bitShift = vec4(1.0, 256.0, 256.0 * 256.0, 256.0 * 256.0 * 256.0);
vec4 rgbaDepth = fract(gl_FragCoord.z * bitShift);

因为rgbaDepth是vec4类型的精度高于8位,还需要将多余的部分砍掉

const vec4 bitMask = vec4(1.0/256.0, 1.0/256.0, 1.0/256.0, 0.0);
rgbaDepth -= rgbaDepth.gbaa * bitMask;

例如:若要保存的深度值为:0.111,经过运算以后是(0.111, 0.416, 0.496, 0.976)

bitMask计算结果是(0.001625, 0.0019375, 0.0038125, 0)

再经过修改变成(0.109375, 0.4140625, 0.4921875, 0.976)

unpack的结果是0.111

由此可见,这里深度范围不能超过1。

unpack解压

d e p t h = r g b a D e p t h . r ∗ 1.0 + r g b a D e p t h . g 256.0 + r g b a D e p t h . b 256.0 ∗ 256.0 + r g b a D e p t h . a 256.0 ∗ 256.0 ∗ 256.0 depth = rgbaDepth.r * 1.0 + \frac{rgbaDepth.g}{256.0} + \frac{rgbaDepth.b}{256.0*256.0}+\frac{rgbaDepth.a}{256.0*256.0*256.0} depth=rgbaDepth.r1.0+256.0rgbaDepth.g+256.0256.0rgbaDepth.b+256.0256.0256.0rgbaDepth.a

这个可以通过内置函数dot()实现

const vec4 bitShit = vec4(1.0, 1.0/256.0, 1.0/256.0/256.0, 1.0/256.0/256.0/256.0);
depth = dot(rgbaDepth, bitShift);

加载三维模型

读取三维模型文件,只需要搞清楚文件格式即可。

读取文件数据

  1. 准备Float32Array类型的数组vertices,从文件中读取模型的顶点坐标数据并保存到其中;
  2. 准备Float32Array类型的数组colors,从文件中读取模型的顶点颜色数据并保存到其中;
  3. 准备Float32Array类型的数组normals,从文件中读取模型的顶点法线数据并保存到其中。
  4. 准备Uint16Array或Uint8Array类型的数组indices,从文件中读取模型的顶点索引数据并保存在其中。
  5. 将前4步获取的数据写入缓冲区中
OBJ文件格式
# Blender v2.60 (sub 0) OBJ File: "
# www.blender.org
mtllib cube.mtl
o Cube
v 1.000000 -1.000000 -1.000000
...
usemtl Material
f 1 2 3 4
...
usemtl Material.001
f 1 5 6 2
  • #开头的行表示注释
  • 引用外部材质文件MTL格式的文件 cube.mtl
  • 指定模型名称
  • 定义顶点的坐标,其中w是可选的,如果没有就默认是1.0
  • 指定某个材质,usemtl <材质名>
  • 列举使用这个材质的表面,每个表面是由顶点、纹理坐标和发现的索引序列定义的。其中v1,v2,v3,v4是顶点的索引值。示例中没有包含法线,如果需要的话格式应该是f v1//vn1 v2//vn2 v3//vn3 …。这里索引值从1开始
  • 示例中,使用另一个材质 usemtl Material.001
MTL文件格式
# Blender MTL File:"
# Material Count: 2
newmtl Material
Ka 0.000000 0.000000 0.000000
Kd 1.000000 0.000000 0.000000
Ks 0.000000 0.000000 0.000000
Ns 96.078431
Ni 1.000000
d 1.000000
illum 0
newmtl Material.001
...
  • 定义一个新材质 newmtl <材质名>
  • 使用Ka, Kd, Ks定义表面的环境色、漫射色和高光色。每个颜色使用RGB格式定义,每个分量值的区间为[0.0, 1.0]。
  • 使用Ks指定高光色的权重,使用Ni指定了表面光学密度,使用d指定了透明度,使用illum指定了光照模型

在理解模型文件的基础上,就可以使用JavaScript文件进行读取和解析了。

OBJ模型解析

**算法步骤 **

  • 准备一个空的缓冲区对象
function main() {
  ...
  // 为顶点坐标、颜色和法线准备空缓冲区对象
  var model = initVertexBuffers(gl, program);
  ...
  // 读取OBJ文件
  readOBJFile('../resources/cube.obj', gl, model, 60, true);
  ...
}
// 创建缓冲区对象并进行初始化
function initVertexBuffers(gl, program) {
  var o = new Object();
  o.vertexBuffer = createEmptyArrayBuffer(gl, program.a_Position, 3, gl.FLOAT);
  o.normalBuffer = createEmptyArrayBuffer(gl, program.a_Normal, 3, gl.FLOAT);
  o.colorBuffer = createEmptyArrayBuffer(gl, program.a_Color, 4, gl.FLOAT);
  o.indexBuffer = gl.createBuffer();
  ...
  return o;
}
// 创建缓冲区对象,并将其分配给相应的attribute变量,并开启之
function createEmptyArrayBuffer(gl, a_attribute, num, type) 
{
  var buffer = gl.createBuffer();
  ...
  gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
  gl.vertexAttribPointer(a_attribute, num, type, false, 0, 0);
  gl.enableVertexAttribArray(a_attribute); // 开启attribute变量
  
  return buffer;
}
  • 读取OBJ文件
// 读取OBJ文件
function readOBJFile(fileName, gl, model, scale, reverse)
{
  var request = new XMLHttpRequest();
  
  request.onreadystatechange = function() {
    if (request.readyState === 4 && request.status !== 404) {
      onReadOBJFile(request.responseText, fileName, gl, model, scale, reverse);
    }
  }
  request.open('GET', fileName, true); // 创建请求
  request.send(); // 发起请求
}
  1. 创建一个XMLHttpRequest对象
  2. 注册事件响应函数,当加载模型文件完成时调用(request.onreadystatechange)
  3. 使用open方法创建一个请求,以加载模型文件
  4. 使用send方法发起请求,开始加载模型文件

事件响应函数

  • 检查加载请求是否发生了错误。readyState是4,表示加载完成了;readyState是404,表示打不开文件
  • 如果成功加载,就调用onReadOBJFile解析模型中的内容。onReadOBJFile第一个参数responseText是字符串形式的模型文件的文本
  • onReadOBJFile调用parse方法将字符串文本解析成WebGL易用的格式
  • 最后将解析好的objDoc对象赋值给全局对象g_objDoc,供其他函数使用

模型读取函数

// 读取
function onReadOBJFile(fileString, fileName, gl, o, scale ,reverse)
{
  var objDoc = new OBJDoc(fileName); // 创建OBJDoc对象
  var result = objDoc.parse(fileString, scale, reverse); // 调用解析方法
  ...
  g_objDoc = objDoc;
}
  • 解析模型文本
// 定义OBJDoc类
// 构造函数
var OBJDoc = function(fileName){
  this.fileName = fileName;
  this.mtls = new Array(0); // 材质MTL列表
  this.objects = new Array(0); // 对象object列表
  this.vertices = new Array(0); // 顶点Vertex列表
  this.normals = new Array(0); // 法线Normal列表
}
// OBJDoc类的parse方法,用来解析obj文件中的字符串文本
OBJDoc.prototype.parse = function(fileString, scale, reverseNormal)
{
  var lines = fileString.split('\n'); // 首先把字符串拆成一行一行
  lines.push(null);	// 添加行末标识
  var index = 0; // 这是行索引
  
  var currentObject = null;
  var currentMaterialName = "";
  
  // 逐行解析
  var line; // 接收当前行文本
  var sp = new StringParser(); // 这是StringParser,自定义字符串解析类的一个实例
  while ((line = lines[index++]) != null){
    sp.init(line); // 初始化sp
    var command = sp.getWord(); // 获取指令名,每一行的第一个单词
    if (command == null) continue;
    switch (command){
      case '#':
        continue; // 这是注释行,直接跳过
      case 'mtllib': // 这行是要读取某个材质文件,文件名是下一个单词
        var path = this.parseMtllib(sp, this.fileName);
        var mtl = new MTLDoc(); // 创建新的MTL类实例
        this.mtls.push(mtl); // 把这个mtl实例推入mtl数组
        // 类似obj文件的方式,使用XMLHttprequest读取mtl文件
        //...
        continue;
      case 'o':
      case 'g':	// 读取对象名称(模型叫啥)
        var object = this.parseObjectName(sp);
        this.objects.push(object); // 把这个模型送入数组
        currentObject = object; // 当前处理的是currentObject模型的数据
        continue;
      case 'v':
        var vertex = this.parseVertex(sp, scale); // 解析顶点,这里scale是缩放因子
        this.vertices.push(vertex); // 顶点推入顶点数组里
        continue;
      case 'vn':
        var normal = this.parseNormal(sp); // 解析法向
        this.normals.push(normal);
        continue;
      case 'usemtl': // 这行要求读取某材质,后面跟的是材质名
        currentMaterialName = this.parseUsemtl(sp);
        continue;
      case 'f': // 读取表面
        var face = this.parseFace(sp, currentMaterialName, this.vertices, reverse);
        currentObject.addFace(face); // 给当前处理的object添加face数据
        continue;
    }
  }
  return true;
}

自定义的StringParse类支持的方法

方法 描述
StringParser.init(str) 初始化StringParser对象
StringParser.getWord() 获取一个单词
StringParser.skipToNextWord() 跳至下一个单词
StringParser.getInt() 获取单词并将其转化为整型
StringParser.getFloat() 获取单词并将其转化为浮点数

不同类型数据的解析方法——以顶点为例

OBJDoc.prototype.parseVertex = function(sp, scale){
  var x = sp.getFloat() * scale;
  ...
  return (new Vertex(x,y,z));
}

把存储在数组的值转成绘制用的数组(Float32Array等格式)

  • 通过for循环计算出顶点索引的数量,然后创建数个类型化数组以分别存储顶点坐标、法线向量、颜色和索引值的数据,并写入相应的缓冲区对象
  • 通过for循环逐步抽取,最外层抽取不同的OBJObject对象;内层则逐对象抽取Face对象;
  • 最内层使用materialName抽取表面的颜色,保存在color中;获取表面的法线向量,保存在faceNormal中
  • 再使用一层for循环,抽取表面的每个顶点索引,将顶点坐标存入vertices中,将颜色值存入colors,法线向量寸入normals。由于有的OJB文件没有发现,这时就用上面解析时的表面法线向量拷贝之。
for (var i = 0; i < this.objects.length; i++){
  var object = this.objects[i];
  for (var j = 0; j < object.faces.length; j++){
    var face = obejct.face[j];
    var color = this.findColor(face.materialName);
    var faceNormal = face.normal;
    for (var k = 0; k < face.vIndices.length; k++){
      // 设置索引
      indices[index_indices] = index_indices;
      // 复制顶点
      var vIdx = face.vIndices[k];
      var vertex = this.vertices[vIdx]; // 从总的那个vertex数组中检索
      vertices[index_indices * 3 + 0] = vertex.x;
      vertices[index_indices * 3 + 1] = vertex.y;
      vertices[index_indices * 3 + 2] = vertex.z;
      // 复制颜色
      colors[index_indices * 4 + 0] = color.r;
      colors[index_indices * 4 + 1] = color.g;
      colors[index_indices * 4 + 2] = color.b;
      colors[index_indices * 4 + 3] = color.a;
      // 复制法向
      var nIdx = face.nIndices[k];
      if (nIdx >= 0){
        // 说明原始文件中有法向信息
        var normal = this.normals[nIdx];
        normals[index_indices * 3 + 0] = normal.x;
        normals[index_indices * 3 + 1] = normal.y;
        normals[index_indices * 3 + 2] = normal.z;
      }
      else{
        // 直接复制表面的法向
        normals[index_indices * 3 + 0] = faceNormal.x;
        normals[index_indices * 3 + 0] = faceNormal.x;
        normals[index_indices * 3 + 0] = faceNormal.x;
      }
      index_indices ++;
    }
  }
}

响应上下文丢失

计算机从休眠中唤醒,或者后台切换,有可能会导致WebGL程序停止,这种现象就是上下文丢失。

如何响应上下文丢失?

webglcontextlost // 上下文丢失事件
webglcontextresotred // 上下文恢复事件

WebGL提供了两个事件,上下文丢失事件和上下文恢复事件。

  • 上下文事件丢失时,由getWebGLContext()获取渲染上下文对象gl就失效了,基于gl的所有操作也都失效了。
  • 浏览器重置WebGL系统后,触发了上下文恢复事件,这是要重新完成上述步骤。
    • 注意:在JavaScript中保存的变量并没有收到影响

给canvas注册上下文丢失和上下文恢复事件的响应函数

canvas.addEventListener(type, handler, useCapture)
// 把handler作为type事件的响应函数注册到canvas元素上去
// 参数:
// 	type:监听事件的名称、字符串
//  handler: 响应函数
//  useCapture: 事件触发后是否捕获。true的话就捕获事件,canvas的父元素就不会触发该事件;false的话,事件触发后还要向上层继续传递
function main(){
  var canvas = document.getElementById('webgl');
  // 注册事件响应函数
  canvas.addEventListener('webglcontextlost', contextLost, false);
  canvas.addEventListener('webglcontextrestored', function(ev){start(canvas);}, false);
  
  start(canvas);
}

调用start方法渲染上下文

function start(canvas)
{
  var gl = getWebGLContext(canvas);
  ...
}

通过把原本main里的绝大多数操作转移到start函数里,当上下文丢失又恢复的时候,再次调用该函数就可以了。

需要注意的是:

  1. 一些上下文丢失后丢失的局部变量,要改到全局变量中。这样就与上下文恢复无关了
  2. 一些函数返回值是在局部调用的,为了防止上下文丢失,可以将返回值保存在全局变量中

上下文丢失响应函数

function contextLost(ev){
  cancelAnimationFrame(g_requestID); // 停止动画
  ev.preventDefault(); // 阻止默认行为
}

上下文丢失响应函数就保证在上下文恢复前,不再尝试重绘、阻止浏览器对该事件的默认处理行为。浏览器对上下文丢失事件的默认处理行为是,不再触发上下文恢复事件。而我们这里要触发上下文恢复事件,所以要组织浏览器的默认行为。

上下文恢复响应函数

调用start即可重置WebGL系统。这里用匿名函数。

你可能感兴趣的:(计算机图形学,图形渲染,算法,JavaScript)