WebGL学习(六)三维世界

293 阅读13分钟

1. 画一个立方体

画一个立方体不仅仅是画出几个面就完了,还要考虑在什么地方 朝哪里看 视野有多宽 能看多远。所以在实现立方体之前需要了解这些概念。 image.png

2. 视点、视线、观察目标点、上方向

视点:简单来说就是观察者,或者就是我们的位置。我们用(eyeX, eyeY, eyeZ)来表示

视线:就是从视点发出的射线。

对于之前我们画的二维图像,视点就是坐标原点,视线就是z轴的负半轴。

观察目标点:被观察目标所在的点,视线就是连接观点和目标点而形成的。用(atX, atY, atZ)来表示

上方向:如果观察者绕视线旋转,那么图像也得确定朝上的方向。用(upX, upY, upZ)来表示

所以,要描述观察者的视角,我们只需要知道视点(在哪看)观察目标点(目标是谁,朝哪里看)上方向(怎么旋转的)

image.png

2.1 视图矩阵

描述视点 目标点 上方向的矩阵,当我们给图像坐标乘以视图矩阵之后,就能得到观察者在不同状态下的图像。

它该如何使用呢?我们通过下面的例子来看,这里有三个层叠的三角形,越靠近我们颜色越深。

image.png

// 顶点着色器
attribute vec4 pos;
// 声明一个矩阵,作为视图矩阵
uniform mat4 viewMat;
// 这个变量是为了渲染不同颜色
varying vec4 vPos;
void main(){
  // 将坐标乘以视图矩阵,得到新的位置
  gl_Position = viewMat * pos;
  vPos = pos;
  gl_PointSize = 20.0;
}
// 片元着色器
precision mediump float;
varying vec4 vPos;
void main(){
  // 根据z的值不同渲染不同颜色
  if(vPos.z == -0.4) {
    gl_FragColor =  vec4(0.83, 1.0, 0.89, 1.0);
  }else if(vPos.z == -0.2) {
    gl_FragColor =  vec4(0.49, 0.98, 0.66, 1.0);
  }else {
    gl_FragColor =  vec4(0.0, 0.97, 0.34, 1.0);
  }
}

import { mat4, vec3 } from 'gl-matrix'
// 省略初始化代码
const pos = gl.getAttribLocation(program, 'pos')
const viewMat = gl.getUniformLocation(program, 'viewMat')
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
// 三个三角形的坐标(x, y, z)
const data = new Float32Array([
  0.0, 0.5, -0.4,
  -0.5, -0.5, -0.4,
  0.5, -0.5, -0.4,

  0.0, 0.5, -0.2,
  -0.5, -0.5, -0.2,
  0.5, -0.5, -0.2,

  0.0, 0.5, 0.0,
  -0.5, -0.5, 0.0,
  0.5, -0.5, 0.0,

])
const eleSize = data.BYTES_PER_ELEMENT
gl.bufferData(
  gl.ARRAY_BUFFER,
  data,
  gl.STATIC_DRAW
)

// viewMat视图矩阵的值使用gl-matrix生成一个mat4
/**
* mat4.lookAt 
*
* 参数1:out,保存结果的变量
* 参数2:eye,观察者坐标
* 参数3:center:目标点坐标
* 参数4:up:上方向(0, 1, 0)表示y轴正方向为上
*/
gl.uniformMatrix4fv(viewMat, false, mat4.lookAt(mat4.create(), vec3.fromValues(0.25, 0.25, 0.25), vec3.fromValues(0, 0, 0), vec3.fromValues(0, 1, 0)))
gl.vertexAttribPointer(pos, 3, gl.FLOAT, false, eleSize * 3, 0)
gl.enableVertexAttribArray(pos)

gl.clearColor(0.4, 0.4, 0.4, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLES, 0, 9)

可以看到核心就是给位置矢量乘了一个视图矩阵gl_Position = viewMat * pos;,这本质上就是前面的图形变换,只是参考不一样。

image.png 如上图,左侧表示图形不动将视点远离图形,右侧表示视点不动将图形远离。左边可以通过改变视图矩阵实现,右边可以改变变换矩阵来实现。

两种观察变换方法,可以使物体变化和观察者变换独立。

我们试试其他视角的这三个三角形是什么样的:

  1. 正视gl.uniformMatrix4fv(viewMat, false, mat4.lookAt(mat4.create(), vec3.fromValues(0, 0, 0), vec3.fromValues(0, 0, 0), vec3.fromValues(0, 1, 0))) ,视点设置为中心,目标点设置为中心,y轴正方形为上。

image.png 可以看到后面几个三角形都被遮挡了,这很符合经验。

  1. 从上偏一点往下看gl.uniformMatrix4fv(viewMat, false, mat4.lookAt(mat4.create(), vec3.fromValues(0, 0.5, 0.5), vec3.fromValues(0, 0, 0), vec3.fromValues(0, 1, 0))) image.png

3.从左偏一点往右gl.uniformMatrix4fv(viewMat, false, mat4.lookAt(mat4.create(), vec3.fromValues(-0.25, 0, 0.2), vec3.fromValues(0, 0, 0), vec3.fromValues(0, 1, 0)))

image.png

2.2 模型视图矩阵

如果你想要在某一个视角看变换后的三角形,可以继续乘以一个变换矩阵。这种组合矩阵就叫模型视图矩阵。 比如:<变换后的坐标> = <视图矩阵> * <模型矩阵> * <原始坐标>

<模型视图矩阵> = <视图矩阵> * <模型矩阵>

// 修改一下上面的代码
const viewMat = mat4.lookAt(mat4.create(), vec3.fromValues(0.25, 0.25, 0.25), vec3.fromValues(0, 0, 0), vec3.fromValues(0, 1, 0))
// 创建一个变换矩阵
const modelMat = mat4.fromRotation(mat4.create(), 30, vec3.fromValues(0, 0, 1))
// 将他们相乘得到模型视图矩阵
gl.uniformMatrix4fv(modelViewMat, false, mat4.multiply(mat4.create(), viewMat, modelMat))

确实旋转了: image.png

2.3 来点好玩的

我们加入键盘来操作左右观察视角。

// 顶点着色器
attribute vec4 pos;
uniform mat4 viewMat;
varying vec4 vPos;
void main(){
  gl_Position = viewMat * pos;
  vPos = pos;
  gl_PointSize = 10.0;
}
// 片元着色器
precision mediump float;
varying vec4 vPos;
void main(){
  if(vPos.z == -0.4) {
    gl_FragColor =  vec4(0.83, 1.0, 0.89, 1.0);
  }else if(vPos.z == -0.2) {
    gl_FragColor =  vec4(0.49, 0.98, 0.66, 1.0);
  }else {
    gl_FragColor =  vec4(0.0, 0.97, 0.34, 1.0);
  }
}
// 略过初始化代码
const pos = gl.getAttribLocation(program, 'pos')
const viewMat = gl.getUniformLocation(program, 'viewMat')
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
const data = new Float32Array([
  0.0, 0.5, -0.4,
  -0.5, -0.5, -0.4,
  0.5, -0.5, -0.4,

  0.0, 0.5, -0.2,
  -0.5, -0.5, -0.2,
  0.5, -0.5, -0.2,

  0.0, 0.5, 0.0,
  -0.5, -0.5, 0.0,
  0.5, -0.5, 0.0,

])
const eleSize = data.BYTES_PER_ELEMENT
gl.bufferData(
  gl.ARRAY_BUFFER,
  data,
  gl.STATIC_DRAW
)

gl.vertexAttribPointer(pos, 3, gl.FLOAT, false, eleSize * 3, 0)
gl.enableVertexAttribArray(pos)

gl.clearColor(0.4, 0.4, 0.4, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLES, 0, 9)
// 上面的代码仍然是画三个三角形
// 视图矩阵抽成了单独的代码,方便键盘事件调用
let eyeX = 0
// 根据输入的视点坐标改变图像
const draw = (eyeX: number) => {
  gl.uniformMatrix4fv(viewMat, false, mat4.lookAt(mat4.create(), vec3.fromValues(eyeX, 0, 0.5), vec3.fromValues(0, 0, 0), vec3.fromValues(0, 1, 0)))
  gl.clearColor(0.4, 0.4, 0.4, 1)
  gl.clear(gl.COLOR_BUFFER_BIT)
  gl.drawArrays(gl.TRIANGLES, 0, 9)
}
draw(eyeX)


document.addEventListener('keydown', (e) => {
  if (e.code === 'ArrowRight') {
    eyeX += 0.01;
  }
  if (e.code === 'ArrowLeft') {
    eyeX -= 0.01;
  }
  draw(eyeX);
})

chrome_8NqTcHJihE.gif

可以看到随着键盘的按下,图像在转变视角。不过有个问题,在转动到一定角度的时候,三角形出现了缺角。

image.png 这是因为我们没有指定可视范围,超出可视范围的部分就被切除了。水平视角 垂直视角 可视深度这些属性组成了可视空间,只有当我们指定了合适的可视空间,图像才会正确的显示。

3. 可视空间

常用的可视空间有两种:

  • 长方体可视空间,盒状空间,由正投影orthographic projection产生
  • 四棱锥/金字塔可视空间,由透视投影perspective projection产生

我们眼中的世界就是透视投影,近大远小,强调的是深度。模拟了相机的成像原理,不同的参考点反射出了不同的大小的图像。

image.png

而正投影就是平行投影,不会受到深度的影响,无论在哪里的参考点大小是固定的,适合对比两个物体:

image.png

3.1 盒状空间(正投影)

盒状空间由下面几个参数组成,下图中的长方体就是一个盒状空间:

  1. 近裁剪面:前面的矩形
  2. 远裁剪面:后面的矩形
  3. 近裁剪面上下左右边界:left、right、top、bottom
  4. 近裁剪面的位置near
  5. 远裁剪面的位置far image.png 根据上一个代码实现一个例子,来观察这些参数的区别:

实现之前,介绍一下投影矩阵,就是将原始坐标转化到投影空间中。这里我们实现一个正投影矩阵。

// 顶点着色器
attribute vec4 pos;
uniform mat4 projectMat;
varying vec4 vPos;
void main(){
  gl_Position = projectMat * pos;
  vPos = pos;
  gl_PointSize = 10.0;
}

// 片元着色器
precision mediump float;
varying vec4 vPos;
void main(){
  if(vPos.z == -0.4) {
    // 离观察者最远的为红色
    gl_FragColor =  vec4(0.97, 0.6, 0.6, 1.0);
  }else if(vPos.z == -0.2) {
    // 稍远的是蓝色
    gl_FragColor =  vec4(0.49, 0.73, 0.98, 1.0);
  }else {
    // 最近的是绿色
    gl_FragColor =  vec4(0.0, 0.97, 0.34, 1.0);
  }
}
// 省略初始化函数
// 省略位置顶点赋值
// 修改一下draw函数,接受near和far两个参数
let near = 0
let far = 0.5

// 创建一个元素显示参数
const info = document.createElement('div')
info.setAttribute('id', 'info')
document.body.append(info)


const draw = (near: number, far: number) => {
  // 显示参数
  info.innerText = `near: ${near.toFixed(2)}   far: ${far.toFixed(2)}`
  const orthoMat = mat4.ortho(mat4.create(), -1, 1, -1, 1, near, far)
  // 进行投影变换
  gl.uniformMatrix4fv(projectMat, false, orthoMat)
  gl.clearColor(0.4, 0.4, 0.4, 1)
  gl.clear(gl.COLOR_BUFFER_BIT)
  gl.drawArrays(gl.TRIANGLES, 0, 9)
}
draw(near, far)


document.addEventListener('keydown', (e) => {
  // 改变near
  if (e.code === 'ArrowRight') {
    near += 0.01
  }
  if (e.code === 'ArrowLeft') {
    near -= 0.01
  }
  // 改变far
  if (e.code === 'ArrowUp') {
    far += 0.01
  }
  if (e.code === 'ArrowDown') {
    far -= 0.01
  }
  draw(near, far)
})

chrome_XUGqzKDeiM.gif

image.png

默认状态下,near值为0,也就是说近裁剪面是由x、y轴组成的平面,所以可以看到z=0的绿色三角形。

near值增大,开始远离观察者,近裁剪面z轴负方向移动,我们便看不到绿色三角形,而是蓝色三角形。

image.png

蓝色三角形的z=-0.2,当near大于0.2时,蓝色三角形也会消失,看到红色三角形。

红色三角形的z=-0.4,当near继续增大超过0.5时,什么也看不见了。

相似的far也一遵循这个逻辑。far值减小,代表远裁剪面在向z轴正方形移动。

这里有一个基于threejs的交互页面

3.2 补上缺角

前面提到过,直接移动视点会发现某些角度,三角形缺了一些,这是由于没有设置合适的可视空间,现在我们设置合适的可视空间,保证三角形不被裁剪。 在当前设置下,图像被裁剪了 image.png 可以发现,原点那个角被裁剪了。

image.png 但是我将远裁剪面放远一点,那个角又出现了。

// 在前面的代码基础上只改变一点
// <投影矩阵>*<视图矩阵>*<坐标点>
const draw = (eyeX: number, near: number, far: number) => {
  // 显示参数
  info.innerText = `near: ${near.toFixed(2)}   far: ${far.toFixed(2)}`
  const orthoMat = mat4.ortho(mat4.create(), -1, 1, -1, 1, near, far)
  const _viewMat = mat4.lookAt(mat4.create(), vec3.fromValues(eyeX, 0, 0.5), vec3.fromValues(0, 0, 0), vec3.fromValues(0, 1, 0))

  // <投影矩阵>*<视图矩阵>
  gl.uniformMatrix4fv(viewMat, false, mat4.multiply(mat4.create(), orthoMat, _viewMat))
  gl.clearColor(0.4, 0.4, 0.4, 1)
  gl.clear(gl.COLOR_BUFFER_BIT)
  gl.drawArrays(gl.TRIANGLES, 0, 9)
}
draw(eyeX, near, far)


document.addEventListener('keydown', (e) => {
  // 改变eyeX
  if (e.code === 'KeyA') {
    eyeX -= 0.01
  }
  if (e.code === 'KeyD') {
    eyeX += 0.01
  }
  // 改变near
  if (e.code === 'ArrowRight') {
    near += 0.1
  }
  if (e.code === 'ArrowLeft') {
    near -= 0.1
  }
  // 改变far
  if (e.code === 'ArrowUp') {
    far += 0.1
  }
  if (e.code === 'ArrowDown') {
    far -= 0.1
  }
  draw(eyeX, near, far)
})

3.3 近裁面宽高比

投影矩阵还有几个参数我们没有改变过,就是近裁剪面的尺寸。

如果我们等比例裁切:

// 我们将尺寸按照比例,缩小了一半
mat4.ortho(mat4.create(), -0.5, 0.5, -0.5, 0.5, -1, 1)

由于canvas并没有缩小,所以图像将会被放大成两倍。

image.png

如果我们没有等比例裁切:

// 高度不变,宽度缩小一半
mat4.ortho(mat4.create(), -0.5, 0.5, -1, 1, -1, 1)

同样的,图像将会被压缩变形,宽度被放大了两倍,高度没变,看起来就是这样。

image.png 这里还需要注意一点,在压缩变形的过程中,超出的部分被裁切了。

3.4 透视投影

简单的说就是近大远小。越远的图像将会被缩小。

图片.png

透视投影空间包含下面几个关键参数:

fov:指垂直视角,可视空间顶部和底部的夹角,必须大于0

aspect:斤裁剪面的宽高比(宽/高)

near、far:必须都大于0,表示远/近裁剪面的位置

图片.png

我们实现一个简单的例子:

// 顶点着色器
attribute vec4 pos;
uniform mat4 viewMat;
varying vec4 vPos;
void main(){
  gl_Position = viewMat * pos;
  vPos = pos;
  gl_PointSize = 10.0;
}
// 片元着色器
precision mediump float;
varying vec4 vPos;
void main(){
  if(vPos.z == -4.0) {
    // 离观察者最远的为红色
    gl_FragColor =  vec4(0.97, 0.6, 0.6, 1.0);
  }else if(vPos.z == -2.0) {
    // 稍远的是蓝色
    gl_FragColor =  vec4(0.49, 0.73, 0.98, 1.0);
  }else if(vPos.z == 0.0){
    // 最近的是绿色
    gl_FragColor =  vec4(0.0, 0.97, 0.34, 1.0);
  }
}
const pos = gl.getAttribLocation(program, 'pos')
const viewMat = gl.getUniformLocation(program, 'viewMat')
const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
const data = new Float32Array([
  -0.75, 1.0, -4.0,
  -1.25, -1.0, -4.0,
  -0.25, -1.0, -4.0,

  -0.75, 1.0, -2.0,
  -1.25, -1.0, -2.0,
  -0.25, -1.0, -2.0,

  -0.75, 1.0, 0.0,
  -1.25, -1.0, 0.0,
  -0.25, -1.0, 0.0,
])
const eleSize = data.BYTES_PER_ELEMENT
gl.bufferData(
  gl.ARRAY_BUFFER,
  data,
  gl.STATIC_DRAW
)

gl.vertexAttribPointer(pos, 3, gl.FLOAT, false, eleSize * 3, 0)
gl.enableVertexAttribArray(pos)

gl.clearColor(0.4, 0.4, 0.4, 1)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.drawArrays(gl.TRIANGLES, 0, 9)

let fov = 65
let aspect = canvas.width / canvas.height
let near = 1
let far = 100
let eyeX = 0

// 创建一个元素显示参数
const info = document.createElement('div')
info.setAttribute('id', 'info')
info.setAttribute('style', 'display: flex; justify-content: center;')
document.body.append(info)

const draw = (eyeX: number, fov: number, aspect: number, near: number, far: number) => {
  // 显示参数
  info.innerText = `fov: ${fov.toFixed(2)}`
  const perspectiveMat = mat4.perspective(mat4.create(), fov, aspect, near, far)
  // 这里的目标视点坐标很有意思,z被设置到了很远处,就是far的位置,这是因为透视点在这个位置
  // 我们实际看的位置也是这里
  const _viewMat = mat4.lookAt(mat4.create(), vec3.fromValues(eyeX, 0, 1), vec3.fromValues(0, 0, -100), vec3.fromValues(0, 1, 0))
  const res = mat4.multiply(mat4.create(), perspectiveMat, _viewMat)
  gl.uniformMatrix4fv(viewMat, false, res)
  gl.clearColor(0.4, 0.4, 0.4, 1)
  gl.clear(gl.COLOR_BUFFER_BIT)
  gl.drawArrays(gl.TRIANGLES, 0, 9)
}
draw(eyeX, fov, aspect, near, far)


document.addEventListener('keydown', (e) => {
  if (e.code === 'ArrowUp') {
    fov += 0.1
  }
  if (e.code === 'ArrowDown') {
    fov -= 0.1
  }
  draw(eyeX, fov, aspect, near, far)
})

msedge_zhpsUCkFiN.gif

随着fov的增大,也就是垂直视角的增大,物体越来越小。想象自己原理一栋房子,视野越来越开阔,但是房子却越来越小。TODO:为什么fov大到一定程度图像会倒置。

值得注意的是,这三个三角形是一样大的。

image.png

threejs教程有个可交互的例子

4. 正确处理前后关系

虽然我们给坐标点设置了z轴的值,画出来的图像看起来也似乎按照z值出现了层叠关系。但是webgl只是简单的按照输入顺序先后画出了图形。

const data = 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, 0.0, 0.4, 0.4, 1.0,  // 紫色
  -0.5, -1.0, 0.0, 0.4, 0.4, 1.0,
  0.5, -1.0, 0.0, 1.0, 0.4, 0.4,
])

z=-4.0 -> z=-2.0 -> z=0.0,画出来是这样

image.png 我们换个顺序:

  0.0, 1.0, 0.0, 0.4, 0.4, 1.0,  // 紫色
  -0.5, -1.0, 0.0, 0.4, 0.4, 1.0,
  0.5, -1.0, 0.0, 1.0, 0.4, 0.4,
  
  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,

image.png 黄色跑到了最前面。

解决这个问题很简单,使用webgl自带的隐藏面消除功能,自动隐藏被遮挡的表面。

  1. 开启功能:gl.enable(gl.DEPTH_TEST)
  2. 绘制之前清除深度缓冲区:gl.clear(gl.DEPTH_BUFFER_BIT)

webgl通过检测像素的深度,通常来说就是z轴的值,来判断是否遮挡。这就是为什么不默认开启,因为直接按照输入顺序渲染可以减少开销。

还要注意的是,必须设置一个可视空间,不然不会有效果

image.png

下面是完整示例:

// 顶点着色器
uniform mat4 viewMat;
attribute vec4 pos;
attribute vec4 color;
varying vec4 _color;
void main(){
  gl_Position = viewMat * pos;
  _color = color;
  gl_PointSize = 10.0;
}
// 片元着色器
precision mediump float;
varying vec4 _color;
void main(){
  gl_FragColor =  _color;
}
// 省略初始化
const pos = gl.getAttribLocation(program, 'pos')
const viewMat = gl.getUniformLocation(program, 'viewMat')
const color = gl.getAttribLocation(program, 'color')

const buffer = gl.createBuffer()
gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
// 顺序不再按照z值排列
const data = new Float32Array([
  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, 0.0, 0.4, 0.4, 1.0,
  -0.5, -1.0, 0.0, 0.4, 0.4, 1.0,
  0.5, -1.0, 0.0, 1.0, 0.4, 0.4,

  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,
])
const eleSize = data.BYTES_PER_ELEMENT
gl.bufferData(
  gl.ARRAY_BUFFER,
  data,
  gl.STATIC_DRAW
)

gl.vertexAttribPointer(pos, 3, gl.FLOAT, false, eleSize * 6, 0)
gl.vertexAttribPointer(color, 3, gl.FLOAT, false, eleSize * 6, eleSize * 3)
gl.enableVertexAttribArray(pos)
gl.enableVertexAttribArray(color)
gl.clear(gl.COLOR_BUFFER_BIT)
gl.clearColor(0.4, 0.4, 0.4, 1)

let fov = 32
let aspect = canvas.width / canvas.height
let near = 1
let far = 100
let eyeX = 0

// 创建一个元素显示参数
const info = document.createElement('div')
info.setAttribute('id', 'info')
info.setAttribute('style', 'display: flex; justify-content: center;')
document.body.append(info)

const draw = (eyeX: number, fov: number, aspect: number, near: number, far: number) => {
  gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT)
  gl.clearColor(0.4, 0.4, 0.4, 1)
  // 显示参数
  info.innerText = `fov: ${fov.toFixed(2)}`
  let transformMat = mat4.create();
  const perspectiveMat = mat4.perspective(mat4.create(), fov, aspect, near, far)
  const _viewMat = mat4.lookAt(mat4.create(), vec3.fromValues(eyeX, 0, 5), vec3.fromValues(0, 0, -100), vec3.fromValues(0, 1, 0))

  // 左移一点
  transformMat = mat4.fromTranslation(mat4.create(), vec3.fromValues(-0.75, 0, 0))
  let res = mat4.multiply(mat4.create(), perspectiveMat, mat4.multiply(mat4.create(), _viewMat, transformMat))
  gl.uniformMatrix4fv(viewMat, false, res)
  gl.drawArrays(gl.TRIANGLES, 0, 9)
  // 平移一点再画一组
  transformMat = mat4.fromTranslation(mat4.create(), vec3.fromValues(0.75, 0, 0))
  res = mat4.multiply(mat4.create(), perspectiveMat, mat4.multiply(mat4.create(), _viewMat, transformMat))
  gl.uniformMatrix4fv(viewMat, false, res)
  gl.drawArrays(gl.TRIANGLES, 0, 9)
}

gl.enable(gl.DEPTH_TEST)
draw(eyeX, fov, aspect, near, far)


document.addEventListener('keydown', (e) => {
  if (e.code === 'ArrowUp') {
    fov += 0.3
  }
  if (e.code === 'ArrowDown') {
    fov -= 0.3
  }
  draw(eyeX, fov, aspect, near, far)
})

image.png 即使输入不一样,也能正确显示出来。

4.1 深度冲突

两图形如果z值很接近,由于浮点数精度的问题,会导致每个像素点实际的深度会有误差,导致图像相互交错。

image.png 好在webgl提供了方法来解决,多边形偏移:设置顶点的z值偏移量

指定加到每个顶点绘制后z值上的偏移量,偏移量按照公式m*factor+r*units计算,其中m表 示顶点所在表面相对于观察者的视线的角度,而r表示硬件能够区分两个Z值之差的最小值。

// 启用
gl.enable(gl.POLYGON_OFFSET_FILL);
// 绘制图形之前设置偏移
gl.polygonOffset(1.0, 1.0); 
gl.drawArrays(gl.TRIANGLES, n / 2, n / 2); 

5. 五彩立方体

image.png 这样一个立方体应该怎么绘制呢,我们可以使用之前的三角形,用两个三角形拼成一个表面,然后再组合成一个立方体,但是这样做太复杂。一个三角形3个顶点,一个立方体面两个三角形6个顶点,6个面总共使用36个顶点,但是一个立方体只有8个顶点。

所以我们需要新的绘制方式:

image.png

  1. 将立方体分为前后左右上下6个面
  2. 每个面有两个三角形,每个三角形使用了三个顶点。比如:front面使用了两个三角形:△012△023
  3. 每个顶点对应一个坐标。

可以看到主要就是两个表,三角形索引表顶点列表,怎么才能让他们联系起来呢,这里要用到gl.drawElements

image.png

下面看看代码:

// 顶点着色器
uniform mat4 viewMat;
attribute vec4 pos;
attribute vec4 color;
varying vec4 _color;
void main(){
  gl_Position = viewMat * pos;
  _color = color;
  gl_PointSize = 10.0;
}

// 片元着色器
precision mediump float;
varying vec4 _color;
void main(){
  gl_FragColor =  _color;
}

// 省略初始化代码
const pos = gl.getAttribLocation(program, 'pos')
const viewMat = gl.getUniformLocation(program, 'viewMat')
const color = gl.getAttribLocation(program, 'color')
var vertexBuffer = gl.createBuffer();
var indexBuffer = gl.createBuffer();
// Create a cube
//    v6----- v5
//   /|      /|
//  v1------v0|
//  | |     | |
//  | |v7---|-|v4
//  |/      |/
//  v2------v3
// 顶点、颜色列表
const vertex = new Float32Array([
  1.0, 1.0, 1.0, 1.0, 1.0, 1.0,  // v0
  -1.0, 1.0, 1.0, 1.0, 0.0, 1.0,  // v1
  -1.0, -1.0, 1.0, 1.0, 0.0, 0.0,  // v2
  1.0, -1.0, 1.0, 1.0, 1.0, 0.0,  // v3
  1.0, -1.0, -1.0, 0.0, 1.0, 0.0,  // v4
  1.0, 1.0, -1.0, 0.0, 1.0, 1.0,  // v5
  -1.0, 1.0, -1.0, 0.0, 0.0, 1.0,  // v6
  -1.0, -1.0, -1.0, 0.0, 0.0, 0.0   // v7
])
// 三角形顶点索引
// 这里只有8个顶点,所以8位就够了
// 如果超过2^8个顶点,就应该用Uint16Array
var indices = new Uint8Array([
  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, 7, 6, 4, 6, 5     // 后
]);
const eleSize = vertex.BYTES_PER_ELEMENT

// 顶点数据绑定缓冲区
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
gl.bufferData(
  gl.ARRAY_BUFFER,
  vertex,
  gl.STATIC_DRAW
)
// 索引数据绑定缓冲区
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);

gl.vertexAttribPointer(pos, 3, gl.FLOAT, false, eleSize * 6, 0)
gl.vertexAttribPointer(color, 3, gl.FLOAT, false, eleSize * 6, eleSize * 3)
gl.enableVertexAttribArray(pos)
gl.enableVertexAttribArray(color)
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
gl.clearColor(0.4, 0.4, 0.4, 1)

let fov = 0.5
let aspect = canvas.width / canvas.height
let near = 1
let far = 100
let eyeX = 3.06
const info = document.createElement('div')
info.setAttribute('id', 'info')
info.setAttribute('style', 'display: flex; justify-content: center;')
document.body.append(info)

const draw = (eyeX: number, fov: number, aspect: number, near: number, far: number) => {
  gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT)
  gl.clearColor(0.4, 0.4, 0.4, 1)
  info.innerText = `fov: ${fov.toFixed(2)}`
  const perspectiveMat = mat4.perspective(mat4.create(), fov, aspect, near, far)
  const _viewMat = mat4.lookAt(mat4.create(), vec3.fromValues(5, 5, 10.0), vec3.fromValues(0, 0, -2), vec3.fromValues(0, 1, 0))
  let res = mat4.multiply(mat4.create(), perspectiveMat, _viewMat)
  gl.uniformMatrix4fv(viewMat, false, res)
  // 使用drawElements绘制索引数据
  gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_BYTE, 0);
}

gl.enable(gl.DEPTH_TEST)
draw(eyeX, fov, aspect, near, far)


document.addEventListener('keydown', (e) => {
  if (e.code === 'ArrowUp') {
    fov += 0.3
  }
  if (e.code === 'ArrowDown') {
    fov -= 0.3
  }
  draw(eyeX, fov, aspect, near, far)
})

image.png

一般三维建模工具可以导出顶点索引和顶点信息。

image.png

虽然我们只调用了一次drawElements,但是webgl内部却调用了indices.length次,因为每一次都要从顶点列表中去除顶点数据。

6.单色立方体

五彩立方体虽然好看,但是按照上面的写法,我们无法指定某个面的单一颜色,因为颜色被定义在了顶点中,这会导致光栅化的时候发生内插,下面就来研究一下怎么画出单一颜色的立方体。

思路:给一个面的所有顶点设置一个颜色,但是有公用顶点,比如前面和右面的连接处有两个公用顶点,如果设置了一个就会影响两个面。

所以,我们把每个面的顶点都单独设置,不再共享顶点,这样就不会出现这种情况了。

image.png

如上图所示,frontright的顶点不再有交叉的情况,都单独设置了一组。

// 顶点着色器
uniform mat4 viewMat;
attribute vec4 pos;
attribute vec4 color;
varying vec4 _color;
void main(){
  gl_Position = viewMat * pos;
  _color = color;
  gl_PointSize = 10.0;
}

// 片元着色器
precision mediump float;
varying vec4 _color;
void main(){
  gl_FragColor =  _color;
}

const pos = gl.getAttribLocation(program, 'pos')
const viewMat = gl.getUniformLocation(program, 'viewMat')
const color = gl.getAttribLocation(program, 'color')
var vertexBuffer = gl.createBuffer();
var colorBuffer = gl.createBuffer();
var indexBuffer = gl.createBuffer();
// Create a cube
//    v6----- v5
//   /|      /|
//  v1------v0|
//  | |     | |
//  | |v7---|-|v4
//  |/      |/
//  v2------v3
// 每个面都单独定义了一组顶点
const vertex = new Float32Array([
  1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0, 1.0,  // v0-v1-v2-v3 前
  1.0, 1.0, 1.0, 1.0, -1.0, 1.0, 1.0, -1.0, -1.0, 1.0, 1.0, -1.0,  // v0-v3-v4-v5 右
  1.0, 1.0, 1.0, 1.0, 1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, 1.0,  // v0-v5-v6-v1 上
  -1.0, 1.0, 1.0, -1.0, 1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0,  // v1-v6-v7-v2 左
  -1.0, -1.0, -1.0, 1.0, -1.0, -1.0, 1.0, -1.0, 1.0, -1.0, -1.0, 1.0,  // v7-v4-v3-v2 下
  1.0, -1.0, -1.0, -1.0, -1.0, -1.0, -1.0, 1.0, -1.0, 1.0, 1.0, -1.0   // v4-v7-v6-v5 后
])
// 分离颜色参数
var 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,  // v0-v1-v2-v3 前(blue)
  0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4,  // v0-v3-v4-v5 右(green)
  1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4, 1.0, 0.4, 0.4,  // v0-v5-v6-v1 上(red)
  1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4,  // v1-v6-v7-v2 左
  1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0,  // v7-v4-v3-v2 下
  0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0, 0.4, 1.0, 1.0   // v4-v7-v6-v5 后
]);
var indices = new Uint8Array([
  0, 1, 2, 0, 2, 3,   // 前
  4, 5, 6, 4, 6, 7,   // 右
  8, 9, 10, 8, 10, 11,   // 上
  12, 13, 14, 12, 14, 15,   // 左
  16, 17, 18, 16, 18, 19,   // 下
  20, 21, 22, 20, 22, 23    // 后
]);
// 绑定顶点缓冲
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer)
gl.bufferData(gl.ARRAY_BUFFER, vertex, gl.STATIC_DRAW)
gl.vertexAttribPointer(pos, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(pos)

// 绑定颜色缓冲
gl.bindBuffer(gl.ARRAY_BUFFER, colorBuffer)
gl.bufferData(gl.ARRAY_BUFFER, colors, gl.STATIC_DRAW)
gl.vertexAttribPointer(color, 3, gl.FLOAT, false, 0, 0)
gl.enableVertexAttribArray(color)

// 绑定索引
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);



let fov = 0.5
let aspect = canvas.width / canvas.height
let near = 1
let far = 100


gl.enable(gl.DEPTH_TEST)
gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT)
gl.clearColor(0.4, 0.4, 0.4, 1)

const perspectiveMat = mat4.perspective(mat4.create(), fov, aspect, near, far)
const _viewMat = mat4.lookAt(mat4.create(), vec3.fromValues(5, 5, 10.0), vec3.fromValues(0, 0, -2), vec3.fromValues(0, 1, 0))
let res = mat4.multiply(mat4.create(), perspectiveMat, _viewMat)
gl.uniformMatrix4fv(viewMat, false, res)
gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_BYTE, 0);

image.png