WebGL学习(八)层次模型

335 阅读7分钟

1. 复杂模型

之前都是画立方体、三角形这些简单的图形,实际上一个复杂的模型就是由这些简单的图形构成的比如下面这个机械手

image.png 如果要考虑关节移动,那就需要考虑更多的相对运动

image.png

手臂动的时候,手掌也会跟着移动,但是手指动的时候,手臂不会跟着动。类似这样的运动在整个手臂上有很多。

对于这样一个复杂的模型,我们只需要一个部件一个部件得画出来,然后组合在一起。对于整体的运动,我们可以对每个部件乘以不同的model矩阵,实现不同组件的不同变化

image.png

2. 单个关节

现在来开始实现这个手臂,我们先实现一个关节,类似这样:

Code_ngHXLM7jeX.gif

这是手臂的一部分,大臂和小臂,可以弯曲可以旋转。

2.1 同时绘制多个物体

可以看到这个关节由两个立方体组成,之前的代码一次就画了一个立方体,每次也只定义了一个立方体的顶点信息,如果要同时画出两个立方体思路很简单:

就是第二个立方体就是第一个立方体变换而来,不同的是在变换之前不清空颜色缓冲区。比如下面的例子

image.png

gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT)
// 把绘制已经转换的代码放进一个draw函数中
// 每次绘制之前没有清除COLOR_BUFFER_BIT
// 所以上一次的绘制仍然显示在图中
const draw = (y: number = 0) => {
  gl.clearColor(0.4, 0.4, 0.4, 1)
  const routateQuat = quat.create()
  quat.rotateY(routateQuat, routateQuat, rotate)
  const modelMat = mat4.fromRotationTranslation(mat4.create(), routateQuat, [0,y,0])
  const perspectiveMat = mat4.perspective(mat4.create(), fov, aspect, near, far)
  const _viewMat = mat4.lookAt(mat4.create(), [3, 3, 7], [0, 0, 0], [0, 1, 0])
  let mvpMat = mat4.multiply(mat4.create(), perspectiveMat, _viewMat)
  mvpMat = mat4.multiply(mat4.create(), mvpMat, modelMat)
  // modelMat的逆转置
  gl.uniformMatrix4fv(normalMatrix, false, mat4.transpose(mat4.create(), mat4.invert(mat4.create(), modelMat)))
  gl.uniformMatrix4fv(modelMatrix, false, modelMat)
  gl.uniformMatrix4fv(mvpMatrix, false, mvpMat)
  gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_BYTE, 0);
}
// 绘制下面的立方体
draw(-1)
// 绘制上面的立方体
draw(2)

2.2. 实现静态关节

先不考虑运动,先实现静态的关节。代码接着上面的代码改。

// 着色器代码不变
// js代码只改绘制部分
gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT)
gl.clearColor(0.4, 0.4, 0.4, 1)

const draw = (modelMat: mat4) => {
  const perspectiveMat = mat4.perspective(mat4.create(), fov, aspect, near, far)
  const _viewMat = mat4.lookAt(mat4.create(), [0, 0, 30], [0, 5, 0], [0, 1, 0])
  let mvpMat = mat4.multiply(mat4.create(), perspectiveMat, _viewMat)
  mvpMat = mat4.multiply(mat4.create(), mvpMat, modelMat)
  // modelMat的逆转置
  gl.uniformMatrix4fv(normalMatrix, false, mat4.transpose(mat4.create(), mat4.invert(mat4.create(), modelMat)))
  gl.uniformMatrix4fv(modelMatrix, false, modelMat)
  gl.uniformMatrix4fv(mvpMatrix, false, mvpMat)
  gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_BYTE, 0);
}

draw(getModelMat([0, -1, 0], -30, [0.7, 1, 0.7]))
draw(getModelMat([0, 5, 0], -30))
/**
 * 获取model矩阵
 * @param translate [x, y, z]平移
 * @param rotate 绕y轴旋转角度
 */
export const getModelMat = (translate: ReadonlyVec3 = [0, 0, 0], rotate: number = 0, scale: ReadonlyVec3 = [1, 1, 1]) => {
  let rad = rotate * Math.PI / 180
  const routateQuat = quat.create()
  quat.rotateY(routateQuat, routateQuat, rad)
  const modelMat = mat4.fromRotationTranslationScale(mat4.create(), routateQuat, translate, scale)
  return modelMat
}

image.png

之后稍微调整一下两个的立方体的位置就可以组成手臂。

2.3. 加入运动

现在加入键盘操控手臂关节的运动,W/S控制前臂弯曲,A/D控制上臂旋转。

但是上面的代码有个问题,两个立方体没有关联,是独立控制运动的,这不符合实际。也就是说小臂应该是在大臂的基础上位移一定距离,他们之间的参数是有关联的。

const drawBox = (modelMat: mat4) => {
  const perspectiveMat = mat4.perspective(mat4.create(), fov, aspect, near, far)
  const _viewMat = mat4.lookAt(mat4.create(), [0, 0, 30], [0, 5, 0], [0, 1, 0])
  let mvpMat = mat4.multiply(mat4.create(), perspectiveMat, _viewMat)
  mvpMat = mat4.multiply(mat4.create(), mvpMat, modelMat)
  // modelMat的逆转置
  gl.uniformMatrix4fv(normalMatrix, false, mat4.transpose(mat4.create(), mat4.invert(mat4.create(), modelMat)))
  gl.uniformMatrix4fv(modelMatrix, false, modelMat)
  gl.uniformMatrix4fv(mvpMatrix, false, mvpMat)
  gl.drawElements(gl.TRIANGLES, indices.length, gl.UNSIGNED_BYTE, 0);
}
const draw = () => {
    // 因为前臂会因为上臂旋转而旋转
    // 所以前臂的model矩阵应该基于上臂做计算
    // 因此先计算上臂再计算前臂
  const upperarmModel = getModelMat([0, 5, 0], [0, -30, 0])
  const forearmModel = setModelMat(upperarmModel, [0, -5, 0], [0, 0, 0], [0.7, 1, 0.7])
  drawBox(upperarmModel)
  drawBox(forearmModel)
}

draw()
/**
 * 在输入的基础上变换,返回新的model矩阵
 * @param modelMat 输入model矩阵
 */
export const setModelMat = (modelMat: mat4, translate: ReadonlyVec3 = [0, 0, 0], rotate: ReadonlyVec3 = [0, 0, 0], scale: ReadonlyVec3 = [1, 1, 1]) => {
  const newModelMat = mat4.create()
  mat4.translate(newModelMat, modelMat, translate)
  rotate.forEach((r, index) => {
    const axis: [number, number, number] = [0, 0, 0]
    axis[index] = 1
    let rad = r * Math.PI / 180
    mat4.rotate(newModelMat, newModelMat, rad, axis)
  })
  mat4.scale(newModelMat, newModelMat, scale)
  return newModelMat
}

现在我们加入键盘操控运动。A/D键控制上臂前后摆动,W/S控制前臂前后摆动。

// draw函数增加两个参数,分别控制上臂和前臂在z轴上的旋转
const draw = (foreArmRotate: number = 0, upperArmRotate: number = 0) => {
  gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT)
  gl.clearColor(0.4, 0.4, 0.4, 1)
  const upperarmModel = getModelMat([0, 5, 0], [0, -30, upperArmRotate])
  const forearmModel = setModelMat(upperarmModel, [0, -5, 0], [0, 0, foreArmRotate], [0.7, 1, 0.7])
  drawBox(upperarmModel)
  drawBox(forearmModel)
}

// 键盘事件
let foreArmRotate = 0
let upperArmRotate = 0
document.addEventListener('keydown', e => {
  // 上臂运动
  if(e.code === 'KeyA') {
    upperArmRotate += 2
  }else if(e.code === 'KeyD') {
    upperArmRotate -= 2
  // 前臂运动
  }else if(e.code === 'KeyW') {
    foreArmRotate += 2
  }else if(e.code === 'KeyS') {
    foreArmRotate -= 2
  }

  draw(foreArmRotate, upperArmRotate)
})

上面代码可以旋转但是有点不对:

msedge_V9853vqs1n.gif 上臂旋转会带动前臂,这是对的,但是前臂旋转明显不对,它脱离了上臂。而且旋转所绕的中心点也不对。是绕着底面中心旋转的。因为底面是该物体坐标原点所在的平面。

// 原因就在顶点的设置上
//    v6----- v5
//   /|      /|
//  v1------v0|
//  | |     | |
//  | |v7---|-|v4
//  |/      |/
//  v2------v3
const vertex = new Float32Array([
const vertex = new Float32Array([
  1.5, 5.0, 1.5, -1.5, 5.0, 1.5, -1.5,  0.0, 1.5,  1.5,  0.0, 1.5, // v0-v1-v2-v3 front
  1.5, 5.0, 1.5,  1.5,  0.0, 1.5,  1.5,  0.0,-1.5,  1.5, 5.0,-1.5, // v0-v3-v4-v5 right
  1.5, 5.0, 1.5,  1.5, 5.0,-1.5, -1.5, 5.0,-1.5, -1.5, 5.0, 1.5, // v0-v5-v6-v1 up
 -1.5, 5.0, 1.5, -1.5, 5.0,-1.5, -1.5,  0.0,-1.5, -1.5,  0.0, 1.5, // v1-v6-v7-v2 left
 -1.5,  0.0,-1.5,  1.5,  0.0,-1.5,  1.5,  0.0, 1.5, -1.5,  0.0, 1.5, // v7-v4-v3-v2 down
  1.5,  0.0,-1.5, -1.5,  0.0,-1.5, -1.5, 5.0,-1.5,  1.5, 5.0,-1.5  // v4-v7-v6-v5 back
   // 之前的立方体
  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 后
])
])
  1. 物体都是绕着本地坐标原点旋转,也就是物体自己的坐标原点
  2. 当物体没有运动变换的时候,物体坐标和世界坐标是重叠的。
  3. 此时底面,-1.5, 0.0,-1.5, /1.5, 0.0,-1.5,/ 1.5, 0.0, 1.5, /-1.5, 0.0, 1.5, // v7-v4-v3-v2,就是原点所在的平面,此时也是本地坐标世界坐标重叠的原点平面。
  4. 随后,对物体进行了平移旋转等变换,本地坐标不再和世界坐标重叠,但是本地坐标原点依然在 v7-v4-v3-v2这几个点组成的平面上。
  5. 所以如果想让前臂正确旋转,应该让前臂的旋转中心转移至上平面

image.png

image.png

const draw = (foreArmRotate: number = 0, upperArmRotate: number = 0) => {
  gl.clear(gl.DEPTH_BUFFER_BIT | gl.COLOR_BUFFER_BIT)
  gl.clearColor(0.4, 0.4, 0.4, 1)
  const upperarmModel = getModelMat([0, 5, 0], [0, -30, upperArmRotate])
  // 这里让前臂绕z轴旋转了180度,相当于底面换到了上面
  // y轴的平移也取消了
  const forearmModel = setModelMat(upperarmModel, [0, 0, 0], [0, 0, foreArmRotate + 180], [0.7, 1, 0.7])
  drawBox(upperarmModel)
  drawBox(forearmModel)
}

msedge_26Khznsf0n.gif 可以看到运动正确了,但是这里还有一点,我们将前臂倒置了,这样不方便我们进行其他操作。所以干脆改变顶点坐标,让上表面和原点平面重合。

// Create a cube
//    v6----- v5
//   /|      /|
//  v1------v0|
//  | |     | |
//  | |v7---|-|v4
//  |/      |/
//  v2------v3
// 每个面都单独定义了一组顶点
const vertex = new Float32Array([
  1.5, 0.0, 1.5, -1.5, 0.0, 1.5, -1.5,  -5.0, 1.5,  1.5,  -5.0, 1.5, // v0-v1-v2-v3 front
  1.5, 0.0, 1.5,  1.5,  -5.0, 1.5,  1.5,  -5.0,-1.5,  1.5, 0.0,-1.5, // v0-v3-v4-v5 right
  1.5, 0.0, 1.5,  1.5, 0.0,-1.5, -1.5, 0.0,-1.5, -1.5, 0.0, 1.5, // v0-v5-v6-v1 up
 -1.5, 0.0, 1.5, -1.5, 0.0,-1.5, -1.5,  -5.0,-1.5, -1.5,  -5.0, 1.5, // v1-v6-v7-v2 left
 -1.5,  -5.0,-1.5,  1.5,  -5.0,-1.5,   1.5,  -5.0, 1.5,    -1.5,  -5.0, 1.5, // v7-v4-v3-v2 down
  1.5,  -5.0,-1.5, -1.5,  -5.0,-1.5, -1.5, 0.0,-1.5,  1.5, 0.0,-1.5  // v4-v7-v6-v5 back
])

3. 多个关节

这里就不展开细说了,实际上就和一个关节一样,只不过要注意层次顺序。

4. 细说webgl初始化

之前没有详细介绍初始化代码是什么意思,这里逐一展开。

// 1.创建顶点、片元着色器
const vertexShader = gl.createShader(gl.VERTEX_SHADER)
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)
// 2.装载着色器代码
gl.shaderSource(vertexShader, vertexCode)
gl.shaderSource(fragmentShader, fragmentCode)
// 3.编译着色器
gl.compileShader(vertexShader);
gl.compileShader(fragmentShader);
// 4.创建WebGLProgram对象
const program = gl.createProgram();
// 5.添加着色器
gl.attachShader(program, vertexShader)
gl.attachShader(program, fragmentShader)
// 6.链接程序
gl.linkProgram(program);
// 7.WebGLProgram对象添加到渲染状态中
gl.useProgram(program);

4.1 createShader

一个program至少需要一个顶点着色器一个片元着色器,createShader就是着色器对象

image.png

4.2 shaderSource

创建着色器对象后,就可以装载源代码

4.3 compileShader

glsl语言类似于c需要编译,webgl执行的是编译之后的代码。

编译的状态可以通过getShaderParmeter获取,

image.png

如果发生错误可以通过getShaderInfoLog(shader)获取日志

4.4 createProgram

着色器对象准备完成后,就开始准备program程序。

4.5 attachShader

将着色器对象绑定在program

4.5 linkProgram

连接两个着色器,这一步的目的是实现两个着色器代码之间的关联。比如顶点着色器中的varying变量和片元着色器中的varying变量对应上。又或者两个着色器中的同名的uniform变量是否是同类型的,等等操作。

同样的,我们可以通过getProgramParameters获取连接状态

image.png

使用getProgramInfoLog获取日志

这里注意一点,没有运行时获取错误信息对性能有影响,所以webgl运行时调试非常不方便。

4.6 useProgram

告知webgl使用哪个program,这一点可以提前准备多个程序对象,在需要的时候切换。实际上可以将模块进行分离。