【《WebGL编程指南》读书笔记-层次模型】

本文为《WebGL编程指南》第九章上半部分读书笔记
总目录链接:https://blog.csdn.net/floating_heart/article/details/124001572

第9章 层次模型(上)

这一章是涉及WebGL的核心特性的最后一章。学习完本章后,你就基本掌握了WebGL,并具有足够的知识来创建逼真、可交互的三维场景。这一章的重点是层次模型。 有了层次模型,你可以在场景中处理复杂的三维模型,如游戏角色、机器人,甚至是人类角色(而不仅仅是三角形或立方体)。
本文是第九章上半部分,具体将涉及:

  • 由多个简单的部件组成的复杂模型。
  • 为复杂物体(机器人手臂)建立具有层次化结构的三维模型。
  • 使用模型矩阵,模拟机器人手臂上的关节运动。

多个对象组成的复杂模型

我们已经知道如何平移、旋转简单的模型,但是,实际用到的很多三维模型都是由多个较为简单的小模型(部件)组成的。

本节将以一个机器人手臂为例,讨论如何处理复杂模型:

在这里插入图片描述

绘制多个小部件组成的复杂模型,最关键的问题是如何处理模型的整体移动,以及各个小部件间的相对移动。

首先考虑一下人类的手臂,从肩部到指尖,包括上臂(肘以上)、前臂(肘以下)、手掌和手指,如下图所示:

在这里插入图片描述

手臂每个部分都可以绕关节运动,当某个部位运动时会带动该部位以下的其它部位一起运动,而位于该部位之上的其它部位不受影响。(此处所有运动都是围绕某个关节的转动。)


层次结构模型

看到此处,肯定有很多读者都想到了很好的方法:为这一复杂的模型划分层次,按照模型中各个部件的层次顺序,从高到低逐一绘制,高层次的模型矩阵会直接作用在低层次部件上(一个部件带动另一个部件),低层次模型矩阵对高层次部件没有影响。下面进行详细说明:

  • 三维模型和现实不同,模型中每个部件并没有真正“连在一起”,而是通过绘制达到同时运动的效果:
    • 如果只有高层次部件运动(例如上臂绕肩关节顺时针转动),实现带动低层次部件(肘关节以下的部位)一起运动,只需要对低层次部件施加与高层次部件同样的模型矩阵即可(对肘关节以下部位施加上臂的模型矩阵);
    • 如果在高层次部件(例如上臂)运动之后(或同时),低层次部件(肘关节以下的部位)发生运动,那么在绘制低层次部件的时候,将低层次部件运动的矩阵(例如肘关节以下部位绕肘关节旋转的矩阵)和高层次部件模型矩阵(例如上臂绕肩关节旋转的矩阵)相乘可以获得低层次部件的模型矩阵,这个矩阵包含了完整的运动信息。

按照上述方式编程,即可简单理解复杂模型的运动。


单关节模型与JointMode.js

首先从单关节的模型开始。示例程序JointMode.js绘制了一个由两个立方体部件组成的机器人手臂,其中arm1可以认为是上臂,arm2可以认为是下臂,肩关节在最下面,arm1连接在下端,arm2连接在arm1的上端(肘关节Joint1)。

在这里插入图片描述

运行程序后,用户可以使用左右方向键控制arm1(同时带动整条手臂)水平转动,使用上下方向键控制arm2绕joint1关节垂直转动。

在这里插入图片描述

在模型矩阵模拟运动之外,示例还加入了视图矩阵和投影矩阵以方便展示,加入了平行光和漫反射使场景更加逼真,加入了键盘点击事件方便操作,因为涉及多个模型,所以示例中调用两次draw相关函数。

按照书中的思路,单个立方体部件的初始模型如下图所示,两次绘制中对该立方体进行不同的几何变换,可得到示例所需的两个立方体。

在这里插入图片描述

代码解析如下:

  • 着色器确定单次绘制的结构。

    这一结构和光照一章中加入环境光的结构基本一致,设置顶点坐标和颜色变量,引入模型视图投影矩阵对顶点坐标进行变换,引入法向量、法向量变换矩阵、光线颜色、光线方向结合顶点基底色对平行光光照效果进行计算,引入环境光颜色结合基底色对环境光效果进行计算。因为是平行光,一个表面平行光的效果一致,不涉及内插的影响,所以采用逐顶点着色而不是逐片元计算颜色。

// 顶点着色器
var VSHADER_SOURCE =
  'attribute vec4 a_Position;\n' +
  'attribute vec4 a_Color;\n' +
  'attribute vec4 a_Normal;\n' + // 法向量
  'uniform mat4 u_MvpMatrix;\n' +
  'uniform mat4 u_NormalMatrix;\n' + // 法向量变换矩阵
  'uniform vec3 u_LightColor;\n' + // 光线颜色
  'uniform vec3 u_LightDirection;\n' + // 光线方向(归一化的世界坐标)
  'uniform vec3 u_AmbientLight;\n' + // 环境光颜色
  'varying vec4 v_Color;\n' +
  'void main(){\n' +
  ' gl_Position = u_MvpMatrix * a_Position;\n' +
  // 对法向量进行归一化
  ' vec3 normal = normalize(vec3(u_NormalMatrix * a_Normal));\n' +
  // 计算光线方向和法向量的点积
  ' float nDotL = max(dot(u_LightDirection,normal),0.0);\n' +
  // 计算漫反射光的颜色
  ' vec3 diffuse = u_LightColor * a_Color.rgb * nDotL;\n' +
  // 计算环境光产生的反射光颜色
  ' vec3 ambient = u_AmbientLight * a_Color.rgb;\n' +
  // 最终颜色
  ' v_Color = vec4(diffuse + ambient,a_Color.a);\n' +
  '}\n'
// 片元着色器
var FSHADER_SOURCE =
  'precision mediump float;\n' +
  'varying vec4 v_Color;\n' +
  'void main(){\n' +
  ' gl_FragColor = v_Color;\n' +
  '}\n'
  • initVertexBuffers()函数初始化顶点坐标和颜色,为绘制操作准备数据。

    此处思路与环境光示例相同,同样将创建缓冲区、绑定缓冲区、向缓冲区分配数据、获取attribute地址、向attribute变量分配缓冲区、开启缓冲区等步骤封装为一个函数,分别对顶点数据、各颜色数据和各顶点法向量数据进行如上操作,顶点索引数据单独处理保存到指向gl.ELEMENT_ARRAY_BUFFER目标的缓冲区中。这些步骤不再展示,下面展示本示例的数据内容(与前面的图片不同,为了方便,笔者将颜色设计为红色。所有顶点都为一个颜色,所有也可省略颜色信息的存储,直接使用uniform变量)。

  // 准备数据
  // 顶点坐标
  let vertices = new Float32Array([
    -1.5, 0.0, 1.5, 1.5, 0.0, 1.5, 1.5, 10.0, 1.5, -1.5, 10.0, 1.5,  // front
    1.5, 0.0, 1.5, 1.5, 0.0, -1.5, 1.5, 10.0, -1.5, 1.5, 10.0, 1.5,  // right
    -1.5, 10.0, 1.5, 1.5, 10.0, 1.5, 1.5, 10.0, -1.5, -1.5, 10.0, -1.5,  // up
    -1.5, 0.0, -1.5, -1.5, 0.0, 1.5, -1.5, 10.0, 1.5, -1.5, 10.0, -1.5,  // 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,  // down
    -1.5, 0.0, -1.5, 1.5, 0.0, -1.5, 1.5, 10.0, -1.5, -1.5, 10.0, -1.5   // back
  ])
  // 顶点颜色
  let colors = new Float32Array([
    1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0,  // front(red)
    1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0,  // right(red)
    1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0,  // up(red)
    1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0,  // left
    1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0,  // down
    1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0  // back
  ])
  // 法向量
  var normals = new Float32Array([
    0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, // front
    1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, // right
    0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, // up
    -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, // left
    0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, // down
    0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0, 0.0, 0.0, -1.0 // back
  ]);
  // 顶点索引
  let 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     // 后
  ])
  • 光线颜色、方向和环境光与模型变换无关,可以在主函数中优先处理。

    此处采用和环境光示例中相同的设计:平行光为高饱和的白光(1.0,1.0,1.0),光线方向(与光传播方向相反)为(0.5,3.0,4.0),环境光颜色为(0.2,0.2,0.2)。

  // 光线相关
  // 光线颜色
  let u_LightColor = gl.getUniformLocation(gl.program, 'u_LightColor')
  if (!u_LightColor) {
    console.log('Failed to get the storage loaction of u_LightColor')
    return
  }
  gl.uniform3f(u_LightColor, 1.0, 1.0, 1.0)
  // 光线方向(世界坐标系)
  let u_LightDirection = gl.getUniformLocation(gl.program, 'u_LightDirection')
  if (!u_LightDirection) {
    console.log('Failed to get the storage loaction of u_LightDirection')
    return
  }
  let lightDirection = new Vector3([0.5, 3.0, 4.0])
  lightDirection.normalize()
  gl.uniform3fv(u_LightDirection, lightDirection.elements)
  // 环境光
  let u_AmbientLight = gl.getUniformLocation(gl.program, 'u_AmbientLight')
  if (!u_AmbientLight) {
    console.log('Failed to get the storage loaction of u_AmbientLight')
    return
  }
  gl.uniform3f(u_AmbientLight, 0.2, 0.2, 0.2)
  • 矩阵变换是示例中的重点。

    我们之前完成过通过键盘操作图形的示例,此处采用相同的思路:1. 确定不变的矩阵;2. 将响应事件封装,处理变化的内容;3. 在全局变量中定义会积累的变化。

  1. 在本示例中,视图和投影矩阵不会变化,我们在主函数中将其定义,并用于后续处理。
  // 计算视图投影矩阵
  let viewProjMatrix = new Matrix4()
  viewProjMatrix.setPerspective(50.0, canvas.width / canvas.height, 1.0, 100.0)
  viewProjMatrix.lookAt(20.0, 10.0, 30.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0)
  1. 键盘响应事件的设计。 因为每次操作都需要重新绘制;根据着色器,每次绘制欠缺的参数包括:法向量变换矩阵u_NormalMatrix、模型视图投影矩阵u_MvpMatrix,对于绘制操作,还需要键盘按键编号、绘图上下文、绘制的顶点数,同时,将前面计算的视图投影矩阵传入以减少重复计算。最终的结果如下:
  // 注册键盘响应事件
  document.onkeydown = function (ev) {
    keydown(ev, gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)
  }
  1. 处理按键参数。 每次按键转动角度、arm1的当前角度和joint1的当前角度是设计标准和变化中积累的量,记录在全局变量中(挂载在window下)。
// 键盘操作标准
var ANGLE_STEP = 3.0 // 每次按键转动角度
var g_arm1Angle = 90.0 // arm1的当前角度
var g_joint1Angle = 0.0 // joint1的当前角度
// 键盘响应函数
function keydown(ev, gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
  // 调整角度
  switch (ev.keyCode) {
    case 38:
      if (g_joint1Angle < 135.0) g_joint1Angle += ANGLE_STEP
      break
    case 40:
      if (g_joint1Angle > -135.0) g_joint1Angle -= ANGLE_STEP
      break
    case 39:
      g_arm1Angle = (g_arm1Angle + ANGLE_STEP) % 360
      break
    case 37:
      g_arm1Angle = (g_arm1Angle - ANGLE_STEP) % 360
      break
    default:
      return
  }
  // 绘制
  draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)
}
  1. 绘制操作。

要点一: 因为要绘制两次立方体,不妨把绘制单个立方体的操作封装为函数,封装的drawBox()函数只负责绘制,矩阵设计由draw()函数完成。(注意清空颜色缓冲区和深度缓冲区的操作需要放在第一个绘图函数的开始。)

要点二: 两次绘制中基于层次结构模型,积累变化在模型矩阵中;此处通过平移操作获得两个立方体的不同初始位置,二者之间留有一定空隙,这样的初始变动也积累于模型矩阵。将模型矩阵记录在全局变量中,必须先绘制高层次部件,再绘制低层次部件,方便矩阵变换的累积;在更小的尺度上,也需要注意单个部件变化的设计(先旋转还是先平移,旋转轴和平移量是多少)。(函数库cuon-matrix.js中Matrix4对象A.multiply(B)的意思是:A.elements = A.elements * B.elements)

要点三: 模型视图投影矩阵和法向量变换矩阵会根据模型矩阵的变化而变化,只能在最终绘制的时候才能确定,为了避免重复定义Matrix4对象,此处将二者的Matrix4类型对象也定义在了全局变量中方便drawBox()函数使用(设计为参数进行传递也可以)。

// 坐标变换矩阵
var g_modelMatrix = new Matrix4(), g_mvpMatrix = new Matrix4()
// 法线的变换矩阵
let g_normalMatrix = new Matrix4()
// 绘制函数
function draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)
  // Arm1
  let arm1Length = 10.0
  g_modelMatrix.setTranslate(0.0, -12.0, 0.0)
  g_modelMatrix.rotate(g_arm1Angle, 0.0, 1.0, 0.0)
  drawBox(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)

  // Arm2 稍作拉伸以示区别
  g_modelMatrix.translate(0.0, arm1Length, 0.0)
  g_modelMatrix.rotate(g_joint1Angle, 0.0, 0.0, 1.0)
  g_modelMatrix.scale(1.3, 1.0, 1.3)
  drawBox(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)
}

function drawBox(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
  // 模型视图矩阵传值
  g_mvpMatrix.set(viewProjMatrix)
  g_mvpMatrix.multiply(g_modelMatrix)
  gl.uniformMatrix4fv(u_MvpMatrix, false, g_mvpMatrix.elements)
  // 法向量变换矩阵传值
  g_normalMatrix.setInverseOf(g_modelMatrix)
  g_normalMatrix.transpose()
  gl.uniformMatrix4fv(u_NormalMatrix, false, g_normalMatrix.elements)
  // 绘制
  gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0)
}

笔者修改了颜色,最终效果图如下图所示:

在这里插入图片描述


多节点模型与MultiJointModel.js

单个模型的实现方案在上一个示例中已经展示,照猫画虎,多节点模型只是更加复杂,实现流程与前者类似。

本例将绘制一个完整的机器人手臂,包括基座(base)、上臂(arm1)、前臂(arm2)、手掌(palm)、两根手指(finger1&finger2),全都可以通过键盘控制。主要的连接结构如下图所示:

在这里插入图片描述

用户可以通过左右方向键控制arm1绕肩部转动,通过上下方向键控制arm2绕肘部转动,通过xz控制手掌绕腕部转动,通过cv控制两根手指绕手指根部转动。如下图所示:

在这里插入图片描述

为了适应更多部件的绘制,示例MultiJointModel.js相比于JointMode.js有更多的改进,下面列举一些重要内容:

  • 将原始立方体设计为标准立方体,将立方体的变形(缩放)过程封装到了drawBox()函数中。 为了满足多部件模型设计的需求,每次都使用不同的物体坐标会占据很多WebGL的缓存空间,所幸示例中的模型都为立方体,我们将标准立方体数据保存在缓冲区,通过变换获得各个部件的模型。
  • 采用栈结构和类似深拷贝的操作,保留矩阵的当前状态。 1. 这对于两个手指的模型矩阵设计十分重要:两个手指都基于手掌的模型矩阵再变换,绘制一个手指之后,全局变量中的模型矩阵已经发生变化,再绘制另一个手指时,需要前一个状态的模型矩阵(基于手掌的)而不是现在的模型矩阵(已经叠加了一个手指的变化)。所以采用栈结构,用类似深拷贝(创建一个新的Matrix4对象,对对象进行赋值。)保存状态入栈(直接压入栈属于浅拷贝,只是压入地址,无法保存状态。),需要时再取出。2. 这种方式也可以服务于drawBox()函数,因为缩放部分放在了drawBox()函数中,每次调用该函数都会对模型矩阵进行缩放操作,但这一操作不应影响其它部件,所以在缩放操作之间保存状态,绘制之后以该状态重置模型矩阵。只要栈的深度足够,此类方法适用于几乎所有层次模型的绘制。

下面对代码细节进行说明:

  • 单个部件的绘制流程没有发生变化,所以着色器代码没有更改。
  • initVertexBuffers()中,顶点坐标数据改为标准立方体(原点位于底面中心的1×1×1立方体)。笔者依然把颜色设为红色,没有更改。
  // 顶点坐标
  let vertices = new Float32Array([
    -0.5, 0.0, 0.5, 0.5, 0.0, 0.5, 0.5, 1.0, 0.5, -0.5, 1.0, 0.5,  // front
    0.5, 0.0, 0.5, 0.5, 0.0, -0.5, 0.5, 1.0, -0.5, 0.5, 1.0, 0.5,  // right
    -0.5, 1.0, 0.5, 0.5, 1.0, 0.5, 0.5, 1.0, -0.5, -0.5, 1.0, -0.5,  // up
    -0.5, 0.0, -0.5, -0.5, 0.0, 0.5, -0.5, 1.0, 0.5, -0.5, 1.0, -0.5,  // left
    -0.5, 0.0, 0.5, 0.5, 0.0, 0.5, 0.5, 0.0, -0.5, -0.5, 0.0, -0.5,  // down
    -0.5, 0.0, -0.5, 0.5, 0.0, -0.5, 0.5, 1.0, -0.5, -0.5, 1.0, -0.5   // back
  ])
  • 模型操作变得复杂,键盘响应事件需要更改。其中g_joint1Angle需要在±135度之间,其它关节没有限制。
// 键盘操作标准
var ANGLE_STEP = 3.0 // 每次按键转动角度
var g_arm1Angle = 90.0 // arm1的当前角度
var g_joint1Angle = 45.0 // joint1的当前角度
var g_joint2Angle = 0.0 // joint2的当前角度
var g_joint3Angle = 0.0 // joint3的当前角度
// 键盘响应函数
function keydown(ev, gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
  // 调整角度
  switch (ev.keyCode) {
    case 38:
      if (g_joint1Angle < 135.0) g_joint1Angle += ANGLE_STEP
      break
    case 40:
      if (g_joint1Angle > -135.0) g_joint1Angle -= ANGLE_STEP
      break
    case 39:
      g_arm1Angle = (g_arm1Angle + ANGLE_STEP) % 360
      break
    case 37:
      g_arm1Angle = (g_arm1Angle - ANGLE_STEP) % 360
      break
    case 90:
      g_joint2Angle = (g_joint2Angle + ANGLE_STEP) % 360
      break
    case 88:
      g_joint2Angle = (g_joint2Angle - ANGLE_STEP) % 360
      break
    case 86:
      g_joint3Angle = (g_joint3Angle + ANGLE_STEP) % 360
      break
    case 67:
      g_joint3Angle = (g_joint3Angle - ANGLE_STEP) % 360
      break
    default:
      return
  }
  // 绘制
  draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)
}
  • 绘制方面是变化最大的地方。

    1.创建栈和相关操作来保存模型矩阵某一刻的状态。 如最开始的分析。因为只需要简单的入栈和出栈操作,JavaScript创建类也不方便,所以只是定义全局变量和全局函数来实现:

// 定义入栈出栈操作
var g_matrixStack = [] // 栈
function pushMatrix(m) { // 入栈操作
  let m2 = new Matrix4(m) // 使用Matrix4自带的方法拷贝矩阵
  g_matrixStack.push(m2)
}
function popMatrix() { // 出栈操作
  return g_matrixStack.pop()
}
  1. draw()函数绘制各个部件。结合各个部件的关系图就能很好地理解这一部分的代码。
// 坐标变换矩阵
var g_modelMatrix = new Matrix4(), g_mvpMatrix = new Matrix4()
// 法线的变换矩阵
let g_normalMatrix = new Matrix4()
// 绘制函数
function draw(gl, n, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
  // 清空颜色缓冲区和深度缓冲区
  gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT)

  // 基座
  let baseHeight = 2.0
  g_modelMatrix.setTranslate(0.0, -12.0, 0.0) // 从y=-12.0的地方开始
  drawBox(gl, n, 10.0, baseHeight, 10.0, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)

  // Arm1
  let arm1Length = 10.0
  g_modelMatrix.translate(0.0, baseHeight, 0.0) // 移至基座
  g_modelMatrix.rotate(g_arm1Angle, 0.0, 1.0, 0.0) // 旋转
  drawBox(gl, n, 3.0, arm1Length, 3.0, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)

  // Arm2
  let arm2Length = 10.0
  g_modelMatrix.translate(0.0, arm1Length, 0.0) // 移至joint1
  g_modelMatrix.rotate(g_joint1Angle, 0.0, 0.0, 1.0) // 旋转
  drawBox(gl, n, 4.0, arm2Length, 4.0, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)

  // palm
  let palmLength = 2.0
  g_modelMatrix.translate(0.0, arm2Length, 0.0) // 移至joint2
  g_modelMatrix.rotate(g_joint2Angle, 0.0, 1.0, 0.0)
  drawBox(gl, n, 2.0, palmLength, 6.0, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)

  // 移至palm顶部中点
  g_modelMatrix.translate(0.0, palmLength, 0.0)
  // 压入模型矩阵状态
  pushMatrix(g_modelMatrix)
  // finger1
  g_modelMatrix.translate(0.0, 0.0, 2.0) // 移至一个手指底部
  g_modelMatrix.rotate(g_joint3Angle, 1.0, 0.0, 0.0)
  drawBox(gl, n, 1.0, 2.0, 1.0, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)

  // 提取模型状态
  g_modelMatrix = popMatrix()
  // finger2
  g_modelMatrix.translate(0.0, 0.0, -2.0) // 移至另一个手指底部
  g_modelMatrix.rotate(-g_joint3Angle, 1.0, 0.0, 0.0) // 反向旋转
  drawBox(gl, n, 1.0, 2.0, 1.0, viewProjMatrix, u_MvpMatrix, u_NormalMatrix)
}
  1. drawBox()函数加入了缩放操作(需要注意模型矩阵状态的保存和恢复)。
function drawBox(gl, n, width, height, depth, viewProjMatrix, u_MvpMatrix, u_NormalMatrix) {
  // 保存模型矩阵状态——入栈
  pushMatrix(g_modelMatrix)
  // 缩放操作
  g_modelMatrix.scale(width, height, depth) // 原本是单位立方体,所以缩放系数就是边长
  // 模型视图矩阵传值
  g_mvpMatrix.set(viewProjMatrix)
  g_mvpMatrix.multiply(g_modelMatrix)
  gl.uniformMatrix4fv(u_MvpMatrix, false, g_mvpMatrix.elements)
  // 法向量变换矩阵传值
  g_normalMatrix.setInverseOf(g_modelMatrix)
  g_normalMatrix.transpose()
  gl.uniformMatrix4fv(u_NormalMatrix, false, g_normalMatrix.elements)
  // 绘制
  gl.drawElements(gl.TRIANGLES, n, gl.UNSIGNED_BYTE, 0)
  // 恢复模型矩阵——出栈
  g_modelMatrix = popMatrix()
}

可见,如果把复杂的问题分解,示例效果还是很好实现的。笔者修改了颜色,效果如下:

在这里插入图片描述


另一种方式绘制部件——脱离入栈出栈的操作

书中给出了另一种绘制部件的方法,能够不适用入栈出栈的操作完成绘制。

入栈出栈的操作是在基础模型为单位立方体的情况下,对基础模型使用模型矩阵进行改造时,避免特定部件的改造模型被后面模型继承所使用的方法,所以摆脱入栈和出栈操作的方法无非以下几种:

  1. **将不需要继承的模型矩阵单独定义并传递给部件绘制函数drawBox()。**这种方式会定义大量的用完就扔的模型矩阵对象,或者新建一个模型矩阵对象来保存此类没有后继影响的矩阵,但是对其它步骤的影响很小。
  2. **对每个模型都定义一组坐标。**这种方法需要将缓冲区存储数据和分配缓冲区步骤分离,书中提出的就是此种方法。

第二种方法:对每一个模型都定义一组坐标

这种方法需要初始化很多组顶点坐标(因为示例中部件都是立方体,所以可以共享法向量和索引值),将顶点坐标保存到缓冲区留待之后使用而不分配。主要有以下几个细节:

  1. 示例中共6个部件,需要6组顶点坐标保存入缓冲区;
  2. 为了使之后的函数能够获得缓冲区对象来进行绑定操作,此处将缓冲区对象定义在全局变量中,在顶点初始化函数中对其赋值;
  3. 因为WebGL系统特性,需要绑定缓冲区后才能借用target对缓冲区进行操作,所以在保存数据的时候需要轮流绑定,分别调用gl.bufferData()存储数据;
  4. 分配缓冲区时,需要提供缓冲区每个顶点的分量个数、分量的数据格式、间隔和偏移量等等,此处借助JavaScript的性质,将需要的参数作为属性挂载在缓冲区对象下,方便调用。
  5. 最后每次调用绘制部件函数时,需要传入相应的缓冲区对象和attribute变量地址,将缓冲区绑定,再根据缓冲区参数将缓冲区分配到变量并开启。

书中将上述3、4步的操作封装为一个函数,函数参数包括绘图上下文,数据、每个顶点的分量个数、分量的数据格式等等,函数创建缓冲区并写入数据,挂载相应属性,最后返回这个缓冲区。该函数能够有效复用。

### 鸿蒙 App 实战项目教程开发指南 #### 1. 学习路径概述 鸿蒙(HarmonyOS NEXT)提供了完整的开发学习路径,适合不同层次的学习者。对于初学者来说,可以从基础技能入手,逐步深入到高级功能的实现。整个学习过程可以划分为四个主要阶段[^1]。 - **第一阶段**:掌握鸿蒙初中级开发必备技能,包括但不限于 ArkTS 编程语言、ArkUI 组件库以及 Stage 模型的应用。 - **第二阶段**:深入了解端部署和分布式应用开发的技术细节,这有助于开发者构建跨设备无缝衔接的应用程序。 - **第三阶段**:探索更深层次的功能和技术点,例如音频处理、视频播放支持、WebGL 图形渲染等媒体技术[^2]。 - **第四阶段**:通过实际项目的开发来巩固所学知识,完成从理论到实践的转化。 #### 2. 实战项目推荐 以下是几个典型的鸿蒙 App 实战项目案例及其特点: ##### (1) 灵感速记 APP 该实战课程旨在引导学员一步步搭建属于自己的笔记类应用程序。它涵盖了界面设计、数据存储管理等个方面,并且强调用户体验优化的重要性。 ```typescript // 示例代码片段:创建一个新的Note对象并保存至数据库中 function addNewNote(title:string, content:string){ let newNote = { title:title, content:content, createTime:new Date().toISOString() }; db.insert('notes',newNote); } ``` ##### (2) 菜谱 App 此项目不仅展示了如何利用现有框架快速构建功能性较强的移动应用,还分享了一些关于页面布局规划的经验之谈。特别是当面对较为复杂的 UI 设计时,建议先对其进行整体架构上的思考后再动手编写具体代码逻辑[^3]。 ```xml <!-- XML布局文件示例 --> <ViewStack> <!-- 头部导航栏 --> <NavBar/> <!-- 主体内容区域 --> <ScrollView scrollDirection="Vertical"> <Text>今日推荐菜品</Text> <!-- 动态加载菜品种类列表 --> {recipes.map(recipe => ( <RecipeCard key={recipe.id} recipe={recipe}/> ))} </ScrollView> <!-- 底部操作按钮 --> <BottomBar/> </ViewStack> ``` ##### (3) 自定义 TabBar 的制作 这是一个专注于提升视觉效果的小型练习课题,重点讲解了如何自定义底部标签栏以满足个性化需求。涉及到工程目录结构划分、各模块间相互调用关系等内容[^4]。 ```javascript // 定义TabBar项的数据模型 class TabBarItem{ constructor(icon,text,path){ this.icon=icon; this.text=text; this.path=path; } } const tabs=[ new TabBarItem("home","首页","/"), new TabBarItem("search","搜索","#search"), new TabBarItem("profile","我的","#me") ]; ``` #### 3. 技术要点总结 无论选择哪个方向作为切入点,在正式进入编码环节之前都需要做好充分准备: - 明确目标用户群体特征; - 对产品核心价值主张有所了解; - 合理安排时间进度表以便按时交付成果物。 同时也要注意保持良好编程习惯比如经常备份源码版本控制工具Git就是不错的选择之一另外还可以借助官方论坛社区寻求技术支持解答疑惑共同进步成长! ---
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值