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.r∗1.0+256.0rgbaDepth.g+256.0∗256.0rgbaDepth.b+256.0∗256.0∗256.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);
加载三维模型
读取三维模型文件,只需要搞清楚文件格式即可。
读取文件数据
- 准备Float32Array类型的数组vertices,从文件中读取模型的顶点坐标数据并保存到其中;
- 准备Float32Array类型的数组colors,从文件中读取模型的顶点颜色数据并保存到其中;
- 准备Float32Array类型的数组normals,从文件中读取模型的顶点法线数据并保存到其中。
- 准备Uint16Array或Uint8Array类型的数组indices,从文件中读取模型的顶点索引数据并保存在其中。
- 将前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(); // 发起请求
}
- 创建一个XMLHttpRequest对象
- 注册事件响应函数,当加载模型文件完成时调用(request.onreadystatechange)
- 使用open方法创建一个请求,以加载模型文件
- 使用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函数里,当上下文丢失又恢复的时候,再次调用该函数就可以了。
需要注意的是:
- 一些上下文丢失后丢失的局部变量,要改到全局变量中。这样就与上下文恢复无关了
- 一些函数返回值是在局部调用的,为了防止上下文丢失,可以将返回值保存在全局变量中
上下文丢失响应函数
function contextLost(ev){
cancelAnimationFrame(g_requestID); // 停止动画
ev.preventDefault(); // 阻止默认行为
}
上下文丢失响应函数就保证在上下文恢复前,不再尝试重绘、阻止浏览器对该事件的默认处理行为。浏览器对上下文丢失事件的默认处理行为是,不再触发上下文恢复事件。而我们这里要触发上下文恢复事件,所以要组织浏览器的默认行为。
上下文恢复响应函数
调用start即可重置WebGL系统。这里用匿名函数。