webgl基础3-三维

355 阅读13分钟

创建视图矩阵

决定视图的参数

三维场景的名词定义:视点、目标点、上方向

image.png 为确定观察者状态,需要获取视点【观察者的位置】、目标点【被观察目标所在的点】两项信息。最后要把观察到的景象绘制到屏幕上,还需要知道上方向; 有这三个信息可以确定观察者看到的内容。

  • 视点:观察者所在的三维空间中位置,视点坐标用(eyeX, eyeY, eyeZ)表示
  • 目标点:被观察目标所在的位置,目标点坐标用(atX, atY, atZ)表示。目标点和视点之间的连线,是视线方向。
  • 上方向:要想最终确定在屏幕上显示的内容,还需要确定一个上方向;如果仅仅确定视点和观察点,观察者还可以以视线为轴进行旋转,这样导致看到的目标点形状就会发生变化;上方向用3个分量的矢量来表示(upX, upY, upZ).

image.png

可视范围

image.png 三维空间中的三维物体只有在可视范围内,WEBGL才会绘制它;

人类只能看到眼前的东西,水平视角大约200度左右。绘制可视范围外的对象没有意义。

辅助函数

  • 归一化函数 normalized:将数据调整到-1到1或者0到1之间
  • 叉集 cross : 获取两个平面的法向量
  • 点集 dot : 获取某点在x、y、z轴的投影长度
  • 向量差:获取目标点和视点的向量
// 归一化函数
function normalized(arr) {
  let sum = 0;

  for (let i = 0; i < arr.length; i++) {
    sum += arr[i] * arr[i]
  }

  const middle = Math.sqrt(sum);

  for (let i = 0; i < arr.length; i++) {
    arr[i] = arr[i] / middle;
  }
}

// 叉积函数 获取法向量
function cross(a,b) {
  return new Float32Array([
    a[1] * b[2] - a[2] * b[1],
    a[2] * b[0] - a[0] * b[2],
    a[0] * b[1] - a[1] * b[0],
  ])
}

// 点积函数 获取投影长度
function dot(a, b) {
  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}

// 向量差
function minus(a, b) {
  return new Float32Array([
    a[0] - b[0],
    a[1] - b[1],
    a[2] - b[2],
  ])
}

通过使用以上函数,推导出视图矩阵

// 视图矩阵获取
function getViewMatrix(eyex, eyey, eyez, lookAtx, lookAty, lookAtz, upx, upy, upz) {

  // 视点
  const eye = new Float32Array([eyex, eyey, eyez])
  // 目标点
  const lookAt = new Float32Array([lookAtx, lookAty, lookAtz])
  // 上方向
  const up = new Float32Array([upx, upy, upz])

  // 确定z轴
  const z = minus(eye, lookAt);

  normalized(z);
  normalized(up);

  // 确定x轴
  const x = cross(z, up);

  normalized(x);
  // 确定y轴
  const y = cross(x, z);

  return new Float32Array([
    x[0],       y[0],       z[0],       0,
    x[1],       y[1],       z[1],       0,
    x[2],       y[2],       z[2],       0,
    -dot(x,eye),-dot(y,eye),-dot(z,eye),1
  ])
}

视图矩阵实例代码

代码通过改变视点位置坐标,形成视图动画效果。

正射投影(平行投影)

概念定义

左侧为透视投影,右侧为正射投影;

在透视投影下,产生的三维场景有近大远小的透视感,更符合真是场景。

正射投影的好处是用户可以方便比较场景中物体的大小,这是因为物体看上去大小与其所在的位置没有关系,在建筑平面图等测绘图中使用。

image.png

下图是正射投影,投射到xy平面的示例。

A点做正射投影映射,转化到A';

首先推导左右区间

用L表示left的值,用R表示right的值

image.png

以此类推上下区间和远近区间公式

image.png 根据矢量点和一个矩阵相乘,推导出x'、y'、z'、w'的值

image.png

x' = ax + by+ cz + d;
y' = ex + fy + gz + h;
z' = ix + jy + kz + l;
w' = mx + ny + oz + p;

将左右区间、上下区间、远近区间公式和上面图中的x'、y'、z'、w'进行等式替换;

// 只有 a= 2/(r-l) 且 d=-(r+l)/(r-l)、b=c=0 时等式成立
// 只有 f= 2/(t-b) 且 h=-(t+b)/(t-b)、e=g=0 时等式成立
// 只有 k= 2/(f-n) 且 l=-(f+n)/(f-n)、i=j=0 时等式成立
// m=n=o=0且p=1

将以上等式矩阵,转为列矩阵,就是下面的正射投影矩阵

正射投影矩阵

// 获取正射投影矩阵
function getOrtho(l, r, t, b, n, f) {
  return new Float32Array([
    2 / (r - l), 0,           0,           0,
    0,           2/(t-b),     0,           0,
    0,           0,           -2/(f-n),    0,
    -(r+l)/(r-l),-(t+b)/(t-b),-(f+n)/(f-n),1
  ])
}

完整示例代码

案例通过移动视点坐标,实现视图改变的动画效果。

透视投影

概念定义

下图左侧为透视投影;

下图右侧在WEBGL场景中放置3个大小同样的三角形,区别是在Z轴的位置不同;视点在(0, 0, 5)位置,可以产生出如下图左侧的效果。

image.png

透视投影符合近大远小的规律;

image.png

先将透视投影缩放到正射投影,然后通过缩放矩阵和正射投影矩阵相乘,得到透视投影矩阵

image.png

推导透视矩阵公式

获取透视比例关系

根据相似三角形定律,可以得出左边的比例关系,从而推导出 y' = yn/fx'=xn/f

image.png

在做映射时,z坐标的长度f是固定不变,f为定长1,所以 y' = ynx'=xn

第一步:推算出a和b的值

从而可以推导出缩放矩阵的第一步

image.png

第二步:获取参数c和d值

然后进行透视矩阵的第二步推导,c和d的值;

image.png 根据相似三角形关系,得到如下等式

image.png 利用齐次坐标的一个性质,所谓齐次坐标就是使用n+1维来表示n维坐标。这里需要用到齐次坐标的一个性质,如果(x,y,z,1)表示投影到w=1平面的点坐标,那么(nx,ny,nz,n)坐标投影到w=1平面的是同一个点(nx/n,nz/n,nz/n,n/n)=(x,y,z,1),这就是齐次坐标的尺度不变性

利用齐次坐标特性,将上面的公式得到的结果同时乘以Ze

image.png

Mpersp为4x4矩阵,根据上面的公式我们能得出矩阵的第一、二、四行的值

image.png


image.png


image.png

第三步根据收缩矩阵和正射投影矩阵的乘积,得到透视投影矩阵

[
  n*2/(r-l) + 0*0 + 0*0 + 0*-(r+l)/(r-l), n*0 + 0*2/(t-b) + 0*0 + 0* -(t+b)/(t-b), n*0 + 0*0 + 0* -2/(f-n) + 0*-(f+n)/(f-n), n*0 + 0*0 + 0*0 +0*1, 
  0*2/(r-l) + n*0 + 0*0 + 0*-(r+l)/(r-l), 0*0 + n*2/(t-b) + 0*0 + 0* -(t+b)/(t-b), 0*0 + n*0 + 0* -2/(f-n) + 0*-(f+n)/(f-n), 0*0 + n*0 + 0*0 +0*1,
  0*2/(r-l) + 0*0 + (f+n)*0+ -1*-(r+l)/(r-l), 0*0+0*2/(t-b)+(f+n)*0+ -1*-(t+b)/(t-b), 0*0+0*0+(f+n)*-2/(f-n)+ -1 *-(f+n)/(f-n),0*0+0*0+(f+n)*0+-1*1,
  0*2/(r-l)+0*0+fn*0+0*-(r+l)/(r-l), 0*0+0*2/(t-b)+fn*0+0*-(t+b)/(t-b), 0*0 + 0*0 + fn* -2/(f-n) + 0*-(f+n)/(f-n), 0*0+0*0+fn*0 + 0*1, 
]
// 由于r+l=0 以及 t+b = 0
// 可以将矩阵简化为
[
  2n/(r-l),  0,          0,            0,
  0,         2n/(t-b),   0,            0,
  0,         0,          -(f+n)/(f-n), -1,
  0,         0,          (-2nf)/(f-n), 0
]
// js中矩阵是列主序的矩阵,所以等到如下图公式

第一列*第一行 = 第一行的第一列

第1列*第2行 = 第1行的第2列

第1列*第3行 = 第1行的第3列

第1列*第4行 = 第1行的第4列

第2列*第1行 = 第2行的第1列

.......

收缩矩阵和正交投影矩阵相乘结果如下:

image.png

第四步通过角度将透视矩阵进行转换

根据视角α 和 宽高比 aspect 求出 top、bottom、left、right四个边界方向

t = n * tan(α /2)

b = -t

r = n * aspect * tan(α/2)

l = -r

计算之后结果

r-l = 2* n * aspect * tan(α/2)

t-b = 2n* tan(α/2)

r+l = 0;

t+b=0

将结果带入到透视投影矩阵中

image.png

透视投影实例代码

通过点击上下左右键盘,可以对视图的物体进行转动

三维场景中2个重要概念

隐藏面消除

正确处理物体的前后关系,是三维场景中重要的概念。前面的物体会阻挡后边物体的显示。在webgl中叫做隐藏面消除。

正常显示前后关系

 var verticesColors = new Float32Array([
   // 顶点坐标和颜色
   0.0, 1.0, -4.0 , 0.4, 1.0, 0.4, // 绿色三角形在后边
   -0.5, -1.0, -4.0 , 0.4, 1.0, 0.4,
   0.5, -1.0, -4.0 , 1.0, 0.4, 0.4,
   
   0.0, 1.0, -2.0 , 1.0, 1.0, 0.4, // 黄色的在中间
   -0.5, -1.0, -2.0 , 1.0, 1.0, 0.4,
   0.5, -1.0, -2.0 , 1.0, 0.4, 0.4,
   
   0.0, 1.0, -1.5 , 0.4, 0.4, 1.0, // 蓝色的前面
   -0.5, -1.0, -1.5 , 0.4, 0.4, 1.0,
   0.5, -1.0, -1.5 , 1.0, 0.4, 0.4,
 ]);

WEBGL中按照定义顶点坐标的顺序来绘制,蓝色的最后定义,所以可以绘制到最外层,同时Z轴坐标也是最靠前,能够正确显示物体的前后关系。

完整示例代码

非正常显示前后关系

接下来调整下顶点坐标的绘制顺序,将蓝色的放到第一个绘制。 image.png

蓝色显示在最后,绿色显示在最前面。这个和定义的Z轴坐标是不符合的。按照定义Z轴坐标的关系,绿色的三角形依然在最后。

使用隐藏消除面,解决前后关系

为了解决不符合Z轴前后关系的问题,webgl提供隐藏消除面的功能。

开启隐藏面消除功能,需要以下2步。

  • 开启隐藏面消除,gl.enable(gl.DEPTH_TEST)
  • 在绘制之前,清除深度缓冲区 gl.clear(gl.DEPTH_BUFFER_BIT);

gl.enable(cap)函数的使用,cap表示开启的功能

  • cap: gl.DEPTH_TEST 或 gl.BLEND 或 gl.POLYGON_OFFSET_FILL

gl.clear()方法清除深度缓冲区

image.png

深度冲突

隐藏面消除可以解决坐标位置前后关系,但是如果Z轴坐标相同的图形,还是会出现问题,使得图像出现斑斑驳驳。这种现象称为深度冲突。

image.png

webgl提供一个多边形偏移的功能,可以解决这个问题。机制是在Z轴上添加一个偏移量,偏移量的值由物体表面相对于观察者视线的角度来确定。

  1. 启用多边形偏移,gl.enable(gl.POLYGON_OFFSET_FILL);
  2. 在绘制之前指定偏移量的参数,gl.polygonOffset(1.0, 1.0);

image.png

image.png

const vm = getViewMatrix(eyex, eyey, eyez, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0);
const perspective = getPerspective(35, ctx.width / ctx.height, 100, 1);
// 创建隐藏面消除
gl.clearColor(0, 0, 0, 1);
gl.enable(gl.DEPTH_TEST);

// 清除视图缓存区
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);

gl.uniformMatrix4fv(mat, false, mixMatrix(vm, perspective));
gl.enable(gl.POLYGON_OFFSET_FILL);
gl.drawArrays(gl.TRIANGLES, 0, 3 * 1);
gl.polygonOffset(1.0, 1.0);
gl.drawArrays(gl.TRIANGLES, 0, 3 * 1);

绘制立方体图形

顶点法

image.png

绘制立方体需要定义8个顶点;

image.png

创建基础模板

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Title</title>
  <style>
    * {
      margin: 0;
      padding: 0;
    }

    canvas{
      margin: 50px auto 0;
      display: block;
      background: yellow;
    }
  </style>
</head>
<body>
  <canvas id="canvas" width="400" height="400">
    此浏览器不支持canvas
  </canvas>
</body>
</html>
<script>
	function initShader(gl, VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE) {
	  const vertexShader = gl.createShader(gl.VERTEX_SHADER);
	  const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);

	  gl.shaderSource(vertexShader, VERTEX_SHADER_SOURCE) // 指定顶点着色器的源码
	  gl.shaderSource(fragmentShader, FRAGMENT_SHADER_SOURCE) // 指定片元着色器的源码

	  // 编译着色器
	  gl.compileShader(vertexShader)
	  gl.compileShader(fragmentShader)

	  // 创建一个程序对象
	  const program = gl.createProgram();

	  gl.attachShader(program, vertexShader)
	  gl.attachShader(program, fragmentShader)

	  gl.linkProgram(program)

	  gl.useProgram(program)

	  return program;
	}
	
	// 视图矩阵获取
	function getViewMatrix(eyex, eyey, eyez, lookAtx, lookAty, lookAtz, upx, upy, upz) {

	  // 视点
	  const eye = new Float32Array([eyex, eyey, eyez])
	  // 目标点
	  const lookAt = new Float32Array([lookAtx, lookAty, lookAtz])
	  // 上方向
	  const up = new Float32Array([upx, upy, upz])

	  // 确定z轴
	  const z = minus(eye, lookAt);

	  normalized(z);
	  normalized(up);

	  // 确定x轴
	  const x = cross(z, up);

	  normalized(x);
	  // 确定y轴
	  const y = cross(x, z);

	  return new Float32Array([
		x[0],       y[0],       z[0],       0,
		x[1],       y[1],       z[1],       0,
		x[2],       y[2],       z[2],       0,
		-dot(x,eye),-dot(y,eye),-dot(z,eye),1
	  ])
	}
	
	// 矩阵复合函数
	function mixMatrix(A, B) {
	  const result = new Float32Array(16);

	  for (let i = 0; i < 4; i++) {
		result[i] = A[i] * B[0] + A[i + 4] * B[1] + A[i + 8] * B[2] + A[i + 12] * B[3]
		result[i + 4] = A[i] * B[4] + A[i + 4] * B[5] + A[i + 8] * B[6] + A[i + 12] * B[7]
		result[i + 8] = A[i] * B[8] + A[i + 4] * B[9] + A[i + 8] * B[10] + A[i + 12] * B[11]
		result[i + 12] = A[i] * B[12] + A[i + 4] * B[13] + A[i + 8] * B[14] + A[i + 12] * B[15]
	  }

	  return result;
	}
	
	// 归一化函数
	function normalized(arr) {
	  let sum = 0;

	  for (let i = 0; i < arr.length; i++) {
		sum += arr[i] * arr[i]
	  }

	  const middle = Math.sqrt(sum);

	  for (let i = 0; i < arr.length; i++) {
		arr[i] = arr[i] / middle;
	  }
	}
	
	// 叉积函数 获取法向量
	function cross(a,b) {
	  return new Float32Array([
		a[1] * b[2] - a[2] * b[1],
		a[2] * b[0] - a[0] * b[2],
		a[0] * b[1] - a[1] * b[0],
	  ])
	}
	
	// 点积函数 获取投影长度
	function dot(a, b) {
	  return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
	}


	// 向量差
	function minus(a, b) {
	  return new Float32Array([
		a[0] - b[0],
		a[1] - b[1],
		a[2] - b[2],
	  ])
	}
	
	// 获取透视投影矩阵
	function getPerspective(fov, aspect, far, near) {
	  fov = fov * Math.PI / 180;
	  return new Float32Array([
		1/(aspect*Math.tan(fov / 2)), 0, 0, 0,
		0, 1/(Math.tan(fov/2)),0,0,
		0,0,-(far+near)/(far-near),-(2*far*near)/(far-near),
		0,0,-1,0,
	  ])
	}

  const ctx = document.getElementById('canvas')

  const gl = ctx.getContext('webgl')

  // 创建着色器源码
  const VERTEX_SHADER_SOURCE = `
    attribute vec4 aPosition;
    attribute vec4 aColor;
    varying vec4 vColor;

    uniform mat4 mat;
    void main() {
      gl_Position = mat * aPosition;
      vColor = (1.0, 0.0, 0.0, 1.0);
    }
  `; // 顶点着色器

  const FRAGMENT_SHADER_SOURCE = `
    precision lowp float;
    varying vec4 vColor;

    void main() {
      gl_FragColor = vColor;
    }
  `; // 片元着色器

  const program = initShader(gl, VERTEX_SHADER_SOURCE, FRAGMENT_SHADER_SOURCE)

  const aPosition = gl.getAttribLocation(program, 'aPosition');
  const aColor = gl.getAttribLocation(program, 'aColor');
  const mat = gl.getUniformLocation(program, 'mat');


  let eyex = 0.0;
  let eyey = -0.1;
  let eyez = 0.2;

  function draw() {
    const vm = getViewMatrix(eyex,eyey,eyez,0.0,0.0,0.0,0.0,0.6,0.0);
    const perspective = getPerspective(150, ctx.width / ctx.height, 100, 1);
   
    gl.uniformMatrix4fv(mat, false, mixMatrix(perspective, vm));
    gl.drawArrays(gl.TRIANGLES, 0, 3 * 6);
	
		requestAnimationFrame(draw);
  }

  draw()
</script>

创建出需要的8个顶点数据

 // 顶点
  const v0 = [1,1,1];
  const v1 = [-1,1,1];
  const v2 = [-1,-1,1];
  const v3 = [1,-1,1];
  const v4 = [1,-1,-1];
  const v5 = [1,1,-1];
  const v6 = [-1,1,-1];
  const v7 = [-1,-1,-1];
// 通过结构顶点数据,组成的面
  const points = new Float32Array([
    ...v0,...v1,...v2, ...v0,...v2, ...v3, // 前
    ...v0,...v3,...v4, ...v0,...v4, ...v5, // 右
    ...v0,...v5,...v6, ...v0,...v6, ...v1, // 上面
    ...v1,...v6,...v7, ...v1,...v7, ...v2, // 左
    ...v7,...v4,...v3, ...v7,...v3, ...v2, // 底
    ...v4,...v7,...v6, ...v4,...v6, ...v5, // 后
  ])

完整绘制代码 绘制出来一个红色正方形,现在还看不出来立方体的形状,可以通过改变视角,进行查看

image.png 通过调整 eyeX、eyeY、eyeZ的值,可以看到透视的正方形

添加旋转动画

image.png

给每个面添加自定义颜色

主要是重新定义个颜色的缓冲对象buffer;

将bufferData数据进行绑定,然后进行colorData赋值;

之后通过gl.vertexAttribPointer给aColor设置值;

索引法

创建顶点数据信息

// 创建顶点数据信息vertices
const vertices = new Float32Array([
   1, 1, 1,
  -1, 1, 1,
  -1,-1, 1,
   1,-1, 1,
   1,-1,-1,
   1, 1,-1,
  -1, 1,-1,
  -1,-1,-1,
])

const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.vertexAttribPointer(aPosition, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(aPosition)

通过索引进行设置面

const indeces = new Uint8Array([
  // 3点组成一个3角形,2个三角形组成一个面
  0,1,2,0,2,3, 
  0,3,4,0,4,5,
  0,5,6,0,6,1,
  1,6,7,1,7,2,
  7,4,3,7,3,2,
  4,6,7,4,6,5,
])
const indexBuffer = gl.createBuffer();
// 绑定索引的buffer数据,需要使用 ELEMENT_ARRAY_BUFFER 方法
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indeces, gl.STATIC_DRAW);

通过索引绘制面,需要使用drawElements函数;

drawElements方法函数的使用

用了绘制面信息,包含4个参数

  • mode:同drawArrays时的参数值
  • count: 要绘制的顶点数量
  • type: 顶点数据类型,值为gl.UNSIGNED_BYTE 或者 gl.UNSIGNED_SHORT
  • offset: 索引数组开始绘制的位置

image.png

完整的索引创建立方体代码

给每个面添加不同颜色

创建顶点缓存数据
// 顶点数据,组成面
const vertices = new Float32Array([
    // 0123,组成前面
    1, 1, 1,
    -1, 1, 1,
    -1,-1, 1,
    1,-1, 1,
    // 0345,组成右面
    1, 1, 1,
    1,-1, 1,
    1,-1,-1,
    1, 1,-1,
    // 0156,组成上面
    1, 1, 1,
    1, 1, -1,
    -1, 1,-1,
    -1, 1,1,
    // 1267,组成左面
    -1, 1, 1,
    -1,1, -1,
    -1, -1,-1,
    -1,-1,1,
    // 2347组成下面
    -1,-1, 1,
    1,-1, 1,
    1,-1,-1,
    -1,-1,-1,
    // 4567组成后面
    1,-1,-1,
    1, 1,-1,
    -1, 1,-1,
    -1,-1,-1,
])
// 顶点位置缓存区
const buffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, vertices, gl.STATIC_DRAW);
gl.vertexAttribPointer(aPosition, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(aPosition)

创建颜色缓存数据

// 创建颜色数据
const colors = new Float32Array([
  0.4,0.4,1.0, 0.4,0.4,1.0, 0.4,0.4,1.0, 0.4,0.4,1.0,
  0.4,1.0,0.6, 0.4,1.0,0.6, 0.4,1.0,0.6, 0.4,1.0,0.6,
  1.0,0.4,0.4, 1.0,0.4,0.4, 1.0,0.4,0.4, 1.0,0.4,0.4,
  1.0,0.8,0.4, 1.0,0.8,0.4, 1.0,0.8,0.4, 1.0,0.8,0.4,
  1.0,0.0,1.0, 1.0,0.0,1.0, 1.0,0.0,1.0, 1.0,0.0,1.0,
  0.0,1.0,1.0, 0.0,1.0,1.0, 0.0,1.0,1.0, 0.0,1.0,1.0,
])
//  创建颜色缓存区
const colorBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer);
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW);
gl.vertexAttribPointer(aColor, 3, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(aColor)

内容参考 《webgl编程指南》

更多资源文档和代码下载地址