WebGL编程指南学习(8)
漫长的旅行即将到达终点……
算法思路
在鼠标左键按下时记录鼠标的初始坐标;然后在鼠标移动的时候用当前坐标减去初始坐标,获得鼠标的位移;然后根据这个位移来计算旋转矩阵。这就需要一个鼠标移动事件的监听器。
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;
};
}
选中三维物体比选中二维物体更加复杂,因为需要更多的数学过程来计算鼠标是否悬浮在某个图形上。可以用一个小技巧来实现——
算法思路
具体代码
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;
}
在上一个例子的基础上,进一步。将“每个像素属于哪个面”的信息写入到颜色缓冲区的 α \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值,就可以知道选取的是哪个平面了。下面关键问题就是怎么把表面编号传给着色器。
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);
}
}
}
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];
}
所谓HUD,就是把一些信息显示在屏幕上,比如文本、二维图形等。需要处理的子任务有两个:
算法思路
核心代码
...
<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所渲染的场景
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();
}
function draw2D(ctx, currentAngle) {
...
}
和WebGL的canvas一样,HUD的canvas有需要在每一帧重绘,因为当前的角度一直在变化
同理,也可以在网页上方叠加三维物体了,原理就是把WebGL绘图的canvas放在网页上方
这里的雾化(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∗(1−z)+y∗z
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,所以可以直接用来近似顶点与视点的距离。
由于光栅化,直接绘制出来的片元就是方形的点。但是通过改动,只绘制圆圈以内的片元,就可以绘制出圆形的点。
片元着色器提供了一个内置变量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;
}
}