携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情 >>
摘要
上一篇文章整理了webgl的基本开发流程;第二篇文章将简单介绍一下WebGL世界中的坐标系,以及不同坐标系之间应该如何转换;最后会实现点击屏幕生成点,以及拖动鼠标画线的交互。
坐标系
在webgl的编码过程中,需要关心的坐标系有三个:
- canvas坐标系
- webgl坐标系
- 纹理坐标系
纹理简单的理解就是图片,可以根据给定的图片对应绘制到canvas上,因为支持绘制局部纹理,所以纹理也有一个独立于webgl的坐标系,用于指定绘制部位,这个坐标系我们将在后续绘制图片的笔记中再详细说明,本次笔记只介绍canvas坐标系以及webgl坐标系
canvas坐标系
- 如上图所示,canvas坐标系以canvas元素的
左上角为原点,向下为y轴正方向,向右为x轴正方向 - 同时由于canvas元素本身具有宽高,其可视范围被限制在其宽
W以及高H内,即其可视范围内的(x, y)坐标满足
0≤x≤W
0≤y≤H
webgl坐标系
- 如上图所示,webgl坐标系以canvas元素的
正中心为原点,向上为y轴正方向,向右为x轴正方向 - 这里可以看出webgl的y轴与canvas的y轴方向是
相反的,这是因为由于当初设计canvas元素的时候还没有将opengl引入web,所以两者坐标系不一致 - 同canvas坐标,webgl也具有可视范围的概念,但与canvas坐标不同,其
(x, y)坐标范围是固定的(-1, 1),即
-1≤x≤1
-1≤y≤1
- 这也体现了另一个问题,假设canvas不是正方形,那么在webgl坐标系下,x和y轴的像素密度是不一样的,这点在开发过程中需要特别注意,尤其是涉及到图形旋转等处理的时候,需要用矩阵去处理,而不能简单的调换x,y坐标
- 虽然webgl和canvas坐标不一样,但canvas上每一个像素点是一样的,也就是每个像素点同时可以通过canvas坐标以及webgl坐标表示,这说明存在一种方式,可以将canvas坐标转换成webgl坐标
坐标系变换 (投影矩阵)
- 假设存在一个坐标系,其
x'的取值范围是
L≤x'≤R
- 要将其变换到另一个坐标系,
x的取值范围是
-1≤x≤1
- 假设两个坐标系之间的点是一一对应的,也就是存在一个常数
C与D使得
x = Cx' + D
- 那么我们可以得到下面的推到过程
x'减L: 0≤ x'-L ≤ R-L
除以R-L: 0≤ (x'-L) / (R-L) ≤ 1
乘以2: 0≤ 2(x'-L) / (R-L) ≤ 2
减1: -1≤ 2(x'-L) / (R-L) - 1 ≤ 1
整理得: -1≤ 2x'/(R-L) - (R+L)/(R-L) ≤ 1
- 同理,假设
T≤y'≤B
W≤z'≤Z
- 可以得到
整理得: -1≤ 2y'/(B-T) - (B+T)/(B-T) ≤ 1
整理得: -1≤ 2z'/(Z-W) - (Z+W)/(Z-W) ≤ 1
- 所以
x = 2x'/(R-L) - (R+L)/(R-L)
y = 2y'/(B-T) - (B+T)/(B-T)
z = 2z'/(Z-W) - (Z+W)/(Z-W)
- 最后如上一篇笔记中提及的,webgl中的坐标是通过
齐次坐标表示的,除了(x,y,z)三个分量,还有一个w分量,用来表示离观察者的距离,这个值在canvas和webgl中,可以理解为相等的,即
w' = w
- 把这个过程表示为矩阵,可以得到
- 由于在一般情况下w恒等于1,同时这里只考虑二维平面,所以
w=w'=1,z=z'=0,并且canvas的y轴与webgly轴方向相反,所y'还要乘一个-1,最后上面左边的矩阵可以变换为
- 这个矩阵,即简化的投影矩阵,可以将任意沿x与y轴各自线性变化的坐标系转换成-1≤x≤1,-1≤y≤1的webgl坐标系
- LRTB分别为canvas的左右上下边界
点击屏幕绘制点
- 复用上一篇文章中的webgl程序,稍微修改一下顶点shader,增加投影矩阵
u_proj
attribute vec2 a_position;
uniform mat4 u_proj;
void main(void) {
gl_Position = u_proj * vec4(a_position, 0, 1.0);
gl_PointSize = 15.0;
}
- 其中
mat4是webgl程序中的一种变量,表示这是一个四阶矩阵,下面我们就要为这个四阶矩阵赋值
function createProjMat (left, right, top, bottom) {
return [
2 / (right - left), 0, 0, 0,
0, 2 / (top - bottom), 0, 0,
0, 0, 1, 0,
-(right + left) / (right - left), -(bottom + top)/(top - bottom), 0, 1
]
}
const projMat = createProjMat(0, gl.canvas.width, 0, gl.canvas.height);
const uProj = gl.getUniformLocation(program, 'u_proj');
gl.uniformMatrix4fv(uProj, false, projMat);
- 这样我们就可以直接把屏幕坐标当作顶点数据传给webgl,顶点shader将根据投影矩阵,自动把顶点坐标转换成webgl坐标
- 细心的朋友这里可能会注意到,在代码中,我们创建的矩阵与上面推导出来的矩阵是转置关系,这是我们把数据传给webgl的时候是以一维数据的形式传递的,这就要求webgl有一种读取一维数组并转换成矩阵的标准,而webgl中是以列为主的方式去读取矩阵数据的,也就是说它会根据你设定的矩阵的阶,读取对应的数量的数据作为矩阵的第一列,接着再读取对应数量的数据作为第二列,以此类推,完成一个矩阵的读取,所以会看到代码中的矩阵与公式里的矩阵互为转置关系
- 完成投影矩阵的赋值,我们就可以开始正式画点了,首先监听点击事件,然后将
(x, y)赋值给webgl
// canvas为通过js获取到的canvas元素
const canvasRect = canvas.getBoundingClientRect();
function drawPoint (x, y) {
const posData = new Float32Array([x, y]);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, posData, gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
const uColor = gl.getUniformLocation(program, 'u_color');
gl.uniform4f(uColor, Math.random(), Math.random(), Math.random(), 1);
gl.drawArrays(gl.POINTS, 0, 1);
}
canvas.addEventListener('mousedown', (e) => {
const x = e.clientX - canvasRect.left;
const y = e.clientY - canvasRect.top;
drawPoint(x, y);
});
- 这里在传递点坐标之前,获取了canvas的DOMRect对象,是因为监听点击获取到的是屏幕坐标,canvas左上角不一定与屏幕左上角重叠,所以要减去偏移,算出相对于canvas左上角的坐标
- 另外修改了颜色的赋值,每次点击时都会生成一个随机数作为点的颜色,结果如下
- 可以看到,目前webgl已经可以在我们点击屏幕的位置绘制出一个点了,但是我们每点击一次,上一个点都会消失,这是因为webgl默认不会保留上一次的绘制数据,绘制完后就会把用到的缓冲清空,本例中就是传递顶点数据用到的buffer,所以下一次绘制又会重新取值,屏幕上永远都只有一个点
- 那么要保留上一次的绘制数据,有两种方法,一种是让webgl保留上一次绘制用到的缓冲数据,一种是用js保存上一次使用的数据
- 先来看第一种方法,很简答,只需要在获取webgl操作对象的时候,加上一个参数即可,
preserveDrawingBuffer,如名保留绘制用到的缓冲,这个开关默认是关闭的
const gl = canvas.getContext('webgl', { preserveDrawingBuffer: true });
- 第二种方法,利用数组,把点击过的点都保留下来,为此需要改造一下
drawPoint方法
const pointArr = [];
function drawPoint (x, y) {
pointArr.push(x, y);
const posData = new Float32Array(pointArr);
...
}
- 但是发现每次点击,之前点的颜色也会一起改变,所以需要把颜色也一起保留下来,改造
drawPoint
const pointArr = [];
const colorArr = [];
function drawPoint (x, y) {
pointArr.push(x, y);
const posData = new Float32Array(pointArr);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, posData, gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
// 每次点击生成一个颜色,并保存起来
colorArr.push(Math.random(), Math.random(), Math.random());
// 如何使每个点对应一个颜色?如何使片元shader中的gl_FragColor动态变化??
...
}
- 写到这里,我们遇到了一个问题,如何使每个顶点对应一个颜色,由于webgl中向量最高是四维,没法再往上添加数据了,没法叠加,那就新增,我们增加一个顶点数据,专门用来存放顶点的颜色,那么下一个问题也来了,顶点数据只能传给顶点shader怎么把这个对应顶点颜色的数据传给片元shader呢,那就是利用webgl中的一种特殊的数据
varying
// 顶点shader
attribute vec2 a_position;
uniform mat4 u_proj;
attribute vec3 a_color;
varying vec3 v_color;
void main(void) {
gl_Position = u_proj * vec4(a_position, 0, 1.0);
gl_PointSize = 15.0;
v_color = a_color;
}
// 片元shader
precision mediump float;
varying vec3 v_color;
void main() {
gl_FragColor = vec4(v_color, 1.0);
}
- 在webgl中,有三种类型的数据,
uniform,attribute,varyinguniform可以在顶点shader中,也可以在片元shader中,代表一种全局变量,在webgl运行过程中,是恒定不变的,只能在运行webgl前通过gl.uniformxxx的方式改变其值attribute只能存在于顶点shader中,通过缓冲读取数值,每次读取一个顶点varying只能在顶点shader的glsl程序中赋值,用途是从顶点shader向片元shader传值,当顶点shader中声明了一个varying变量,在片元shader中,只需要声明一个同名的varying变量,就可以将顶点shader中的数据传到片元shader中
- 弄清这三种变量之间的关系后,接着改造
drawPoint
...
function drawPoint (x, y) {
// 顶点数据
...
// 每次点击生成一个颜色,并保存起来
colorArr.push(Math.random(), Math.random(), Math.random());
const colorData = new Float32Array(colorArr);
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colorData, gl.STATIC_DRAW);
const aColor = gl.getAttribLocation(program, 'a_color');
gl.enableVertexAttribArray(aColor);
gl.vertexAttribPointer(aColor, 3, gl.FLOAT, false, 0, 0);
gl.drawArrays(gl.POINTS, 0, pointArr.length / 2);
}
- 这样就实现了保留上一个点击点的绘制,
preserveDrawingBuffer和js保存都能实现上图的效果,不考虑对每个点的单独控制时,使用preserveDrawingBuffer即可,需要控制每个点的显示与否,则需要用到js
点击屏幕绘制线
绘制线段基本和绘制点流程大体相同,区别在,绘制线需要监听鼠标点击落下和弹起的事件,从而确认线段的两个点,同时鼠标移动过程中,要实时刷新绘图,让线段更着鼠标移动,这些只需要改js代码即可,shader不需要改动。
- 监听鼠标事件
const lineStart = { x: 0, y: 0 };
let lineColor = [0, 0, 0];
let startDraw = false;
canvas.addEventListener('mousedown', (e) => {
// 只需记录起点,无需进行webgl绘制
// 同时生成该线段的颜色
startDraw = false;
lineStart.x = e.clientX - canvasRect.left;
lineStart.y = e.clientY - canvasRect.top;
lineColor = [Math.random(), Math.random(), Math.random()];
});
canvas.addEventListener('mousemove', (e) => {
// 防止没有点击时鼠标滑过canvas也触发绘制
if (!startDraw) return;
// 当前移动到的点记录为终点,同时进行绘制
lineEnd.x = e.clientX - canvasRect.left;
lineEnd.y = e.clientY - canvasRect.top;
drawLine();
});
canvas.addEventListener('mouseup', (e) => {
// 绘制结束,把最初点击的点,以及最后移动到的点塞进数组,用于下一次绘制
startDraw = false;
pointArr.push(lineStart.x, lineStart.y, lineEnd.x, lineEnd.y);
pointColorArr.push(...lineColor, ...lineColor);
});
drawLine实现,本质和画点一样,将顶点数据绑定到缓冲,指明取值方式,以及绘制类型
function drawLine () {
// 由于此时pointArr没有记录当前正在绘制的点,所以需要结合linestart生成实时顶点数据
const drawPoints = [...pointArr, lineStart.x, lineStart.y, lineEnd.x, lineEnd.y];
const posData = new Float32Array(drawPoints);
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, posData, gl.STATIC_DRAW);
const aPos = gl.getAttribLocation(program, 'a_position');
gl.enableVertexAttribArray(aPos);
gl.vertexAttribPointer(aPos, 2, gl.FLOAT, false, 0, 0);
// 同pointArr,pointColorArr也没有记录当前点的颜色
// 另外注意到,这里lineColor插入了两次,一个顶点对应一个颜色
const colorArr = [...pointColorArr, ...lineColor, ...lineColor];
const colorData = new Float32Array(colorArr);
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colorData, gl.STATIC_DRAW);
const vColor = gl.getAttribLocation(program, 'a_color');
gl.enableVertexAttribArray(vColor);
gl.vertexAttribPointer(vColor, 3, gl.FLOAT, false, 0, 0);
// 绘制类型是LINES,使用到的顶点数量依旧是drawPoints.length / 2
gl.drawArrays(gl.LINES, 0, drawPoints.length / 2);
}
- 这里需要注意的一点是,绘制线段时,颜色被插入了两次,这是因为顶点shader运行的次数是由
drawPoints.length / 2决定的,如果只插入一个颜色,那最终运行时,colorBuffer将会由于数据不足,而导致报错 - 补充:片元shader的颜色数据是从顶点传递过去的,它会根据顶点的相对位置,进行插值,计算出对应像素的颜色,这就要求两个点都具有一个颜色,反过来说,如果我们设置两个点为不同的颜色,将能绘制出具有渐变色的线段,为此做如下改动
canvas.addEventListener('mousedown', (e) => {
...
// 一开始就生成2个点的颜色
lineColor = [Math.random(), Math.random(), Math.random(), Math.random(), Math.random(), Math.random()];
});
canvas.addEventListener('mouseup', (e) => {
...// 插入一次即可
pointColorArr.push(...lineColor);
});
function drawLine () {
...
// 插入一次即可
const colorArr = [...pointColorArr];
...
}
成品如下,源码在此:
总结
本文主要整理了二维空间下,canvas到webgl的坐标变换方式,然后通过点击屏幕绘制点的方式,演示了其是如何作用的,最后在绘制点的基础上,实现了点击屏幕绘制线段;webgl能绘制的图形有三种,点,线,三角形,所以下一篇文章将会整理三角形的绘制方式,以及基于三角形的绘制,分析如何利用webgl导入图片,并将图片绘制到canvas上。