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

WebGL编程指南学习(8)

漫长的旅行即将到达终点……

8. 高级技术

8.1 用鼠标控制物体旋转

如何实现物体的旋转?

  • 如何旋转物体?使用MVP矩阵来变换顶点的坐标;
  • 根据鼠标的移动情况创建旋转矩阵,更新MVP矩阵

算法思路

在鼠标左键按下时记录鼠标的初始坐标;然后在鼠标移动的时候用当前坐标减去初始坐标,获得鼠标的位移;然后根据这个位移来计算旋转矩阵。这就需要一个鼠标移动事件的监听器。

  • 注册event handler
  • tick()函数,执行动画
function main() {
  ...
  // 注册event handler
  var currentAngle = [0.0, 0.0]; // 当前的旋转角度
  initEventHandlers(canvas, currentAngle);
  ...
  var tick = function() {
    draw(gl, n, viewProjMatrix, u_MvpMatrix, currentAngle);
    requestAnimationFrame(tick, canvas);
  }
  tick();
}
  • 鼠标移动事件监听器
function initEventHandlers(canvas, currentAngle) {
  var dragging = false;
  var lastX = -1, lastY = -1; // 鼠标的最后一个位置
  
  canvas.onmousedown = function(ev) { // 鼠标按下的事件
    var x = ev.clientX, y = ev.clientY;
    // 如果鼠标位置在画布(canvas)里,开始拖拽
    var rect = ev.target.getBoundingClinetRect();
    if (rect.left <= x && x < rect.right &&
       rect.top <= y && y < rect.bottom) {
      lastX = x; lastY = y;
      dragging = true;
    }
  };
  
  canvas.onmouseup = function(ev) { dragging = false; } // 鼠标释放的事件
  
  canvas.onmousemove = function(ev) {	// 鼠标移动的事件
    var x = ev.clientX, y = ev.clientY; // 侦听到当前鼠标位置
    if (dragging) {
      var factor = 100 / canvas.height; // 旋转率
      var dx = factor * (x - lastX);
      var dy = factor * (y - lastY);
      // 注意把Y轴的旋转角度限制在-90到90Y,这只是一个人为限制,没有特殊意义
      currentAngle[0] = Math.max(Math.min(currentAngle[0] + dy, 90.0), -90.0);
      currentAngle[1] = currentAngle[1] + dx;
    }
    lastX = x; lastY = y;
  };
}

8.2 选中物体

选中三维物体比选中二维物体更加复杂,因为需要更多的数学过程来计算鼠标是否悬浮在某个图形上。可以用一个小技巧来实现——

算法思路

  • 当鼠标左键按下时,将整个立方体重绘为单一的红色;
  • 读取鼠标点击处的像素颜色;
  • 使用立方体原来的原色对其进行重绘(立即,否则用户就能看出来)
  • 如果第2步读到的颜色是红色,就显示消息"The cube was selected!"

具体代码

  • 对顶点着色器进行修改,添加一个u_Clicked变量,这个变量其实就是个flag。当外部传进来true的时候,就将立方体绘制成红色;否则还是绘制立方体该有的颜色
var VSHADER_SOURCE = 
  ...
  'uniform bool u_Clicked;\n' + // 鼠标按下
  ...
  '	if (u_Clicked) {\n' +
  '		v_Color = vec4(1.0, 0.0, 0.0, 1.0);\n' +
  '	}else{\n' +
  '		v_Color = a_Color;\n' +
  '	}\n' +
  ...
function main() {
  ...
  var u_Clicked = gl.getUniformLocation(gl.program, 'u_Clicked');
  ...
  gl.uniform1i(u_Clicked, 0); // 将false传给u_Clicked变量
}
  • 鼠标按下的事件监听器
function main() {
  ...
	canvas.onmousedown = function(ev) {	// 鼠标按下时
  	var x = ev.clientX, y = ev.clientY;
  	var rect = ev.target.getBoundingClientRect();
  	if (...) {
        // 检查是否点击在物体上
        var x_in_canvas = x - rect.left;
        var y_in_canvas = rect.bottom - y;
        var picked = check(gl, n, x_in_canvas, y_in_canvas, currentAngle, u_Clicked, viewProjMatrix, u_MvpMatrix);
  			if (picked) alert('The cube was selected!');
    }
	};
	...
}
  • check()读取鼠标点的颜色,判断是否是红色
function check(gl, n, x, y, currentAngle, u_Clicked, viewProjMatrix, u_MvpMatrix) {
  var picked = false;
  gl.uniform1i(u_Clicked, 1); // 通知顶点着色器,把立方体绘制成红色
  draw(gl, n, currentAngle, viewProjMatrix, u_MvpMatrix);
  // 读取点击位置的像素颜色值
  var pixels = new Uint8Array(4); // 存储像素的数组
  gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
  if (pixels[0] = 255) // pixel[0]是255,说明点击在物体上了
    picked = true;
  gl.uniform1i(u_Clicked, 0); // 将false传给着色器,通知着色器以正常状态绘制立方体
  draw(gl, n, currentAngle, viewProjMatrix, u_MvpMatrix);
  
  return picked;
}
  • 这种方法比较讨巧。但是如果三维模型过于复杂,或者绘图区域较大,这种方法代码写起来很繁琐。为了解决这个问题,可能可以使用简化的模型,或者缩小绘图区域,或者也可以使用帧缓冲区对象

8.3 选中某个表面

在上一个例子的基础上,进一步。将“每个像素属于哪个面”的信息写入到颜色缓冲区的 α \alpha α分量中。

  • 顶点着色器中,添加一个表面编号;还有一个被选中表面的编号
var VSHADER_SOURCE = 
  ...
  'attribute float a_Face;\n' + // 表面编号(不可使用int类型)
  ...
  'uniform int u_PickedFace;\n' + // 被选中表面的编号
  ...
  '	int face = int(a_Face);\n' + // 转成int类型
  '	vec3 color = (face == u_PickedFace) ? vec3(1.0) : a._Color.rgb\n' + // 如果u_PickedFace是true,就把立方体绘制成白色
  '	if (u_PickedFace == 0) {\n' + // 将表面编号写入alpha分量
  '		v_Color = vec4(color, a_Face/255.0);\n' +
  '	} else {\n' +
  '		v_Color = vec4(color, a_Color.a);\n' +
  '	}\n' +
  ...

这样一来,通过readPixel读取到alpha值,就可以知道选取的是哪个平面了。下面关键问题就是怎么把表面编号传给着色器。

  • 在主函数里注册事件响应函数(event handler)
function main() {
  ...
  // 初始化被选装的表面
  gl.uniform1i(u_PickedFace, -1);
  ...
  // 注册事件响应函数
  canvas.onmousedown = function(ev) {	// 当鼠标被按下时
    var x = ev.clientX, y = ev.clientY;
    var rect = ev.target.getBoundingClientRect();
    if (判断是否在canvas里) {
      // 如果点集的位置在canvas里,则更新表面
      var x_in_canvas = x - rect.left, y_in_canvas = rect.bottom -y;
      var face = checkFace(gl, n, x_in_canvas, y_in_canvas, currentAngle, u_PickedFace, viewProjMatrix, u_MvpMatrix);
      gl.uniform1i(u_PickedFace, face); // 传入表面编号
      draw(gl, n, currentAngle, viewProjMatrix, u_MvpMatrix);
    }
  }
}
  • 初始化顶点buffer的时候,多一个表面编号的数组
function initVertexBuffer(gl) {
  ...
  var faces = new Uint8Array([
    // 表面编号
    1, 1, 1, 1, // v0-v1-v2-v3 前表面
    ...
  ]);
  ...
}
  • 通过checkFace()检查读取到的鼠标当前位置的像素的alpha值对应的是哪个表面
function checkFace(gl, n, x, y, ...) {
  var pixels = new Uint8Array(4);
  gl.uniform1i(u_PickedFace, 0); // 通知着色器,将表面编号写入到alpha分量里
  draw(gl, n, ...);
  // 读取鼠标当前位置的像素颜色,并返回alpha通道的值
  gl.readPixels(x, y, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixels);
  
  return pixels[3];
}

8.4 HUD (Head up display)

所谓HUD,就是把一些信息显示在屏幕上,比如文本、二维图形等。需要处理的子任务有两个:

  • 怎么实时获取信息,比如角度、位置信息——由WebGL提供
  • 怎么在屏幕上绘制出来——由Javascript完成

算法思路

  1. 在HTML文件中,除为WebGL准备的canvas之外,再准备一个为二维HUD信息的canvas。令这两个canvas重叠放置,并且HUD的canvas在上面;
  2. 在第一个canvas上使用WebGL API绘制三维场景
  3. 在第二个canvas上使用canvas 2D API绘制HUD信息

核心代码

  • 两个重叠的canvas
...
<canvas id="webgl" width="400" height="400" style="position: absolute;z-index: 0">canvas>
<canvas id="hud" width="400" height="400" style="position: absolute; z-index: 1">canvas>

z-index表示两个canvas的上下关系,规则是:具有较大的z-index属性值的元素在上面;

由于默认canvas背景色是透明的,所以无序其他处理,用户就能透过HUD的canvas看到WebGL所渲染的场景

  • 获取HUD的canvas的绘图上下文,用来绘制三角形和文本
function main() {
  ...
  var canvas = document.getElementById('webgl');
  // 获取hud canvas
  var hud = document.getElementById('hud');
  ...
  // 获取WebGL绘图上下文
  var gl = getWebGLContext(canvas);
  // 获取二维绘图上下文
  var ctx = hud.getContext('2d');
  ...
  // 注册事件响应函数
  hud.onmousedown = function(ev){	// 鼠标按下
    ...;
  }
  
  var tick = function() {	// 开始绘制
    currentAngle = animate(currentAngle);
    draw2D(ctx, currentAngle); // 绘制二维图形
    draw(gl, n, currentAngle, viewProjMatrix, u_MvpMatrix);
    requestAnimationFrame(tick, canvas);
  }
  tick();
}
  • 在HUD的绘图上下文中绘制二维图形
function draw2D(ctx, currentAngle) {
  ...
}

和WebGL的canvas一样,HUD的canvas有需要在每一帧重绘,因为当前的角度一直在变化

同理,也可以在网页上方叠加三维物体了,原理就是把WebGL绘图的canvas放在网页上方

8.5 雾化(大气效果)

这里的雾化(fog)是描述远处物体看起来比较模糊的现象。这里要实现一个雾化的场景,用户可以使用上下方向键调节雾的浓度。

如何实现雾化?

用数学的方法实现。最简单的一种方法是线性雾化,由点到视点的距离决定,距离越远雾化程度越高。线性雾化有起点和终点。起点表示开始雾化,终点表示完全雾化,两点之间某一点的雾化程度与该点与视点的距离呈线性关系。比终点更远的点也是完全雾化;比起点更近的点是完全没有雾化。

雾化程度使用雾化因子表示:
雾 化 因 子 = 终 点 − 当 前 点 与 视 点 间 的 距 离 终 点 − 起 点 雾化因子= \frac{终点-当前点与视点间的距离}{终点-起点} =
在片元着色器中,根据雾化因子计算片元的颜色:
片 元 厌 恶 = 物 体 表 面 颜 色 ∗ 雾 化 因 子 + 雾 的 颜 色 ∗ ( 1 − 雾 化 因 子 ) 片元厌恶= 物体表面颜色*雾化因子+雾的颜色*(1-雾化因子) =+1
代码实现

  • 在顶点着色器中计算顶点与视点的距离
...
uniform mat4 u_ModelMatrix;
uniform vec4 u_Eye; // 视点,世界坐标系下
varying vec4 v_Color;
varying float v_Dist; // 视点与顶点的距离
void main(){
  gl_Position = u_MvpMatrix * a_Position;
  v_Color = a_Color;
  v_Dist = distance(u_ModelMatrix * a_Position, u_Eye);
}
  • 在片元着色器中计算雾化后的颜色
...
uniform vec3 u_FogColor; // 雾的颜色
uniform vec2 u_FogDist;	 // 雾化的起点和终点
varying vec4 v_Color;
varying float v_Dist;
void main() {
  // 计算雾化因子
  float fogFactor = clamp((u_FogDist.y - v_Dist) / (u_FogDist.y - u_FogDist.x), 0.0, 1.0);
  vec3 color = mix(u_FogColor, vec3(v_Color), fogFactor);
  gl_FragColor = vec4(color, v_Color.a);
}

顶点着色器计算顶点与视点间的距离:首先将顶点坐标转换到世界坐标系下,然后调用内置函数distance()计算视点坐标到顶点坐标的距离;

内置函数clamp()是把第一个参数的值限制在第二个和第三个参数之间,否则则返回区间的最小值或最大值。这里就是计算结果在0到1之间,如果大于1就返回1;如果小于0就返回0;

内置函数mix()计算 x ∗ ( 1 − z ) + y ∗ z x*(1-z)+y*z x(1z)+yz

  • 通过JavaScript创建上述计算所需要的变量
function main() {
  ...
  // 雾的颜色
  var fogColor = new Float32Array([0.137, 0.231, 0.423]);
  // 雾化的起点和终点与视点的距离【起点距离,终点距离】
  var fogDist = new Float32Array([55, 80]);
  // 视点在世界坐标系下的位置
  var eye = new Float32Array([25, 65, 35]);
  ...
  // 传递给着色器
  gl.uniform3fv(u_FogColor, fogColor);
  ...
  // 设置背景色,并开启隐藏面消除功能
  gl.clearColor(fogColor[0], fogColor[1], fogColo[2], 1.0);
  ...
  document.onmousedown = function(ev) { 
  	keydown(ev, gl, n, u_FogDist, fogDist);};
}

使用其他的雾化算法,只需要在着色器中修改雾化指数的计算方法即可

使用w分量

在顶点着色器中计算顶点与视点的距离会造成较大的开销,也许会影响性能。可以使用另外一种方法来近似估算这个距离:使用顶点经过MVP变换后的坐标的w分量。

这个w分量的值就是顶点的视图坐标的z分量乘以-1.在视图坐标系中,视点在原点,视线沿着z轴负方向,观察者看到的物体的z分量都是负的。而gl_Position的w分量的值恰好是z值乘以-1,所以可以直接用来近似顶点与视点的距离。

8.6 绘制圆形的点

由于光栅化,直接绘制出来的片元就是方形的点。但是通过改动,只绘制圆圈以内的片元,就可以绘制出圆形的点。

片元着色器提供了一个内置变量gl_PointCoord,帮助我们绘制圆形的点

vec4 gl_PointCoord; // 片元在被绘制的点内的坐标(从0.0到1.0)

gl_PointCoord变量表示当前片元在所属的点内的坐标,坐标值的区间是从0.0到1.0。为了将矩形削成圆形,需要将与点的中心(0.5, 0.5)距离超过0.5的片元剔除掉,可以在片元着色器中使用内置函数discard()表示放弃当前片元

void main(){
  float dist = distance(gl_PointCoord, vec2(0.5, 0.5));
  if (dist < 0.5) {
    gl_FragColor = vec4(1.0, 0.0, 0.0, 1.0);
  }else {
    discard;
  }
}

你可能感兴趣的:(计算机图形学,图形渲染,算法,几何学,虚拟现实)