webgl笔记(二) ——— 坐标系

790 阅读11分钟

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第2天,点击查看活动详情 >>

摘要

上一篇文章整理了webgl的基本开发流程;第二篇文章将简单介绍一下WebGL世界中的坐标系,以及不同坐标系之间应该如何转换;最后会实现点击屏幕生成点,以及拖动鼠标画线的交互。

坐标系

在webgl的编码过程中,需要关心的坐标系有三个:

  • canvas坐标系
  • webgl坐标系
  • 纹理坐标系

纹理简单的理解就是图片,可以根据给定的图片对应绘制到canvas上,因为支持绘制局部纹理,所以纹理也有一个独立于webgl的坐标系,用于指定绘制部位,这个坐标系我们将在后续绘制图片的笔记中再详细说明,本次笔记只介绍canvas坐标系以及webgl坐标系

canvas坐标系

image.png

  • 如上图所示,canvas坐标系以canvas元素的左上角为原点,向下y正方向向右x正方向
  • 同时由于canvas元素本身具有宽高,其可视范围被限制在其宽W以及高H内,即其可视范围内的(x, y)坐标满足

0≤x≤W

0≤y≤H

webgl坐标系

image.png

  • 如上图所示,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

  • 假设两个坐标系之间的点是一一对应的,也就是存在一个常数CD使得

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

  • 把这个过程表示为矩阵,可以得到
[2/(RL)00(R+L)/(RL)/w02/(BT)0(B+T)/(BT)/w002/(ZW)(Z+W)/(ZW)/w0001][xyzw]=[xyzw]\begin{bmatrix} 2/(R-L) & 0 & 0 & - (R+L)/(R-L)/w' \\ 0 & 2/(B-T) & 0 & - (B+T)/(B-T)/w' \\ 0 & 0 & 2/(Z-W) & - (Z+W)/(Z-W)/w' \\ 0 & 0 & 0 & 1 \\ \end{bmatrix} * \begin{bmatrix} x' \\ y' \\ z' \\ w' \end{bmatrix} = \begin{bmatrix} x \\ y \\ z \\ w \end{bmatrix}
  • 由于在一般情况下w恒等于1,同时这里只考虑二维平面,所以w=w'=1z=z'=0,并且canvas的y轴与webgly轴方向相反,所y'还要乘一个-1,最后上面左边的矩阵可以变换为
[2/(RL)00(R+L)/(RL)02/(TB)0(B+T)/(TB)00100001]\begin{bmatrix} 2/(R-L) & 0 & 0 & - (R+L)/(R-L) \\ 0 & 2/(T-B) & 0 & - (B+T)/(T-B) \\ 0 & 0 & 1 & 0 \\ 0 & 0 & 0 & 1 \\ \end{bmatrix}
  • 这个矩阵,即简化的投影矩阵,可以将任意沿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左上角的坐标
  • 另外修改了颜色的赋值,每次点击时都会生成一个随机数作为点的颜色,结果如下 Kapture 2022-08-10 at 11.14.59.gif
  • 可以看到,目前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);
  ...
  
}

Kapture 2022-08-10 at 11.50.26.gif

  • 但是发现每次点击,之前点的颜色也会一起改变,所以需要把颜色也一起保留下来,改造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中,有三种类型的数据,uniformattributevarying
    • uniform可以在顶点shader中,也可以在片元shader中,代表一种全局变量,在webgl运行过程中,是恒定不变的,只能在运行webgl前通过gl.uniformxxx的方式改变其值
    • attribute只能存在于顶点shader中,通过缓冲读取数值,每次读取一个顶点
    • varying只能在顶点shaderglsl程序中赋值,用途是从顶点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);
}

Kapture 2022-08-10 at 11.39.56.gif

  • 这样就实现了保留上一个点击点的绘制,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];
  ...
}

成品如下,源码在此

Kapture 2022-08-11 at 18.32.47.gif

总结

本文主要整理了二维空间下,canvas到webgl的坐标变换方式,然后通过点击屏幕绘制点的方式,演示了其是如何作用的,最后在绘制点的基础上,实现了点击屏幕绘制线段;webgl能绘制的图形有三种,点,线,三角形,所以下一篇文章将会整理三角形的绘制方式,以及基于三角形的绘制,分析如何利用webgl导入图片,并将图片绘制到canvas上。