WebGL Tutorial
and more

动画

撰写时间:2024-01-27

修订时间:2024-02-01

层级模型变化

Box.gltf文件中有下面的矩阵数据:

"nodes": [ { "children": [ 1 ], "matrix": [ 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, -1.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0 ] }, { "mesh": 0 } ], "meshes": [ { "primitives": [ { "attributes": { "NORMAL": 1, "POSITION": 2 }, "indices": 0, "mode": 4, "material": 0 } ], "name": "Mesh" } ]

第0个nodematrix属性,这属于模型变换,在渲染第0个node时,应先应用此模型变换再渲染。

而第0个node又有children属性,意味着这个matrix属性也应传递给所有的下级mesh节点。

这种设计很容易理解。设想一辆小车的模型,4个车轮随车体走。因此,可在车体上设置一个模型变换,当车体的模型变换发生改变时,4个车轮均继承车体的模型变换,这样整车就可一致协调地工作。

但与我们应用框架的设计所不同的是,该模型变换并未直接设置在Mesh上面,这对通过各个Mesh来追踪上下级的模型变换带来了一定的困难。

基于此点,既然我们不能简单地添加GLTF的各个Mesh,我们就从其源头,也即带有上下层级关系GLTFnodes都添加到应用框架的Scene中。

更进一步,我们可以设计一个GLTFHierarchyNodes类,让其在内部管理这些复杂的上下级关系,最后,只需对外提供一个renderInViewport接口方法即可。

客户端代码很简单:

app.doInInitMeshes((scene) => { let glTFLoader = new GLTFLoader(); glTFLoader.loadFile(uiObj.file) .then(glTFObj => { initDatGui(glTFObj); showScene(scene, glTFObj); }); }); ... function showScene(scene, glTFObj) { let hierNodes = new GLTFHierarchyNodes(glTFObj.scene.nodes); scene.add(hierNodes); ViewportManager.instance.doOnCameraViewChanged(); }

使用glTFObjscenenodes属性创建GLTFHierarchyNodes的一个实例,然后将此实例添加进scene中。

现在,轮到GLTFHierarchyNodes类来进行内部管理并对外提供渲染接口了。该类的代码如下:

import { mat4, SolidMesh, ST_ROTATE } from './index-independent.js'; import {FileTextureImage, TextureMesh} from './TextureMesh.js'; export class GLTFHierarchyNodes { nodes = null; gltfMeshWrappers = []; constructor(sceneNodes) { this.nodes = sceneNodes; this.applyHierMatrices(); this.visible = true; } ... }

在其构造方法中,applyHierMatrices方法将从上级节点向下级节点传递模型变换。

applyHierMatrices方法如下:

applyHierMatrices() { for (let nodeWrapper of this.nodes) { if (nodeWrapper.hasOwnProperty('children') || nodeWrapper.hasOwnProperty('mesh')) { this.processMeshNode(nodeWrapper, null); } } } processMeshNode(meshWrapper, parentMatrix) { if (meshWrapper.hasOwnProperty('children')) { if (!meshWrapper.matrix) { meshWrapper.matrix = mat4.create(); } for (let childMesh of meshWrapper.children) { this.processMeshNode(childMesh, meshWrapper.matrix); } } else if (meshWrapper.hasOwnProperty('mesh')) { if (!meshWrapper.matrix) { meshWrapper.matrix = mat4.create(); } if (parentMatrix) { mat4.mul(meshWrapper.matrix, meshWrapper.matrix, parentMatrix); } let frameWorkMesh; if (meshWrapper.mesh.primitives[0].material.pbrMetallicRoughness.hasOwnProperty('baseColorTexture')) { frameWorkMesh = this.getTextureMesh(meshWrapper.mesh); } else { frameWorkMesh = this.getSolidMesh(meshWrapper.mesh); } meshWrapper.frameWorkMesh = frameWorkMesh; mat4.mul(frameWorkMesh.modelMatrix, frameWorkMesh.modelMatrix, meshWrapper.matrix); this.gltfMeshWrappers.push(meshWrapper); } else { console.log(meshWrapper); throw new Error(`Error! It might not be a mesh node!`); } }

processMeshNode是一个递归方法,其参数包括了当前mesh的包装对象及上级的矩阵。

由于各个节点可以没有matrix属性,因此需同时检查上级及本级是否有该属性,如果没有则创建。然后,将上级的matrix与本级的matrix相乘并赋值于本级的matrix属性。之后,像之前一样创建应用构架的Mesh类的实例,并将传递过来的矩阵应用到其modelMatrix属性上面。

最后,meshWrapper持有frameWorkMesh的引用,并且各个meshWrapper都统一归置于类的实例变量gltfMeshWrappers下面。这样做的目的是为了加快渲染速度。

下面是渲染方法:

renderInViewport(viewportRenderMode) { for (let wrapper of this.gltfMeshWrappers) { wrapper.frameWorkMesh.renderInViewport(viewportRenderMode); } }

渲染方法的代码是超高频运行的地方,其代码必须惜墨如金,能不进行递归遍历就绝不进行。因此上面两行代码:

meshWrapper.frameWorkMesh = frameWorkMesh; this.gltfMeshWrappers.push(meshWrapper);

促成了这种效果。

运行应用

四元数

生成四元数

GLTF的旋转变换使用的是四元数,而平移与缩放使用的是矢量。本节中我们先了解如何生成四元数。

四元数本身是一个相对比较复杂的概念,但我们化繁为简,只需知道四元数可以很方便地进行旋转变换就行了。

先创建一个四元数。

let rotateQuat = quat.create(); console.log(rotateQuat);

显示:

[0, 0, 0, 1]

这是经过归一化的四元数。

现在,让此四元数围绕Z轴旋转45度。

quat.rotateZ(rotateQuat, rotateQuat, Geo.RadianFromDegree(45)); console.log(rotateQuat);

quatrotateZ方法的第3个参数是弧度。因我们较为熟悉角度,因此我们可指定角度值并通过Geometries的静态方法RadianFromDegree进行转换。

旋转后,rotateQuat的值变为:

[0, 0, 0.38, 0.92] // 仅显示两位小数

看这个数值,我们很难看出具体的旋转轴及其旋转角度。可不管它。

还有另外一种方法可以直接生成围绕Z轴旋转45度的四元数:

let rotateQuat = quat.create(); quat.fromEuler(rotateQuat, 0, 0, 45); console.log(rotateQuat); // [0, 0, 0.38, 0.92]

quatfromEuler方法从分别代表X, Y, Z轴旋转角度值的第2、第3、第4个参数生成一个相应的四元数。该方法使用的是角度为计量单位。从打印的结果来看,与上一种方法的四元数的数值完全一致。

应用四元数

现在,我们有一个围绕Z轴旋转45度的四元数。而让网格对象应用此变换,我们需要将其转换为矩阵。

let rotateMatrix = mat4.create(); mat4.fromQuat(rotateMatrix, rotateQuat);

最后,将网格对象的modelMatrix与该矩阵相乘即可:

let vertices = Geo.GenSquareVertices(0.5); let mesh = new SolidMesh(vertices); mat4.mul(mesh.modelMatrix, mesh.modelMatrix, rotateMatrix); scene.add(mesh);

运行应用

同时应用平移、缩放与旋转

与旋转所使用的四元数不同,GLTF的平移与缩放变换使用的是3维向量。很幸运,glMatrixmat4提供了一个同时综合应用所有这3种变换的方法。

let rotateQuat = quat.create(); quat.fromEuler(rotateQuat, 0, 0, 45); let translateVec = [-0.5, 0, 0]; let scaleVec = [1.5, 1.5, 1.5]; let trsMatrix = mat4.create(); mat4.fromRotationTranslationScale(trsMatrix, rotateQuat, translateVec, scaleVec);

正如其方法名称所示,fromRotationTranslationScale方法后3个参数依序代表旋转、平移及缩放的数值。矩阵乘法次序很重要,而该方法在内部依照平移、旋转、缩放的正确顺序先后相乘。

最后,应用trsMatrix矩阵变化。

let vertices = Geo.GenSquareVertices(0.5); let mesh = new SolidMesh(vertices); mat4.mul(mesh.modelMatrix, mesh.modelMatrix, trsMatrix); scene.add(mesh);

运行应用

网格对象缩放了1.5倍,旋转了45度,最后往左平移了0.5个单位。

加载有模型变换的GLTF文件

这一节,我们应对上下级节点可能有不同模型变换的情况。为充分地说明问题,我将BoxTextured.gltf文件复制为BoxTextured-alt.gltf文件,并作如下改动:

"nodes": [ { "children": [ 1 ], "rotation": [0, 0, 0.38, 0.92], "scale": [1.2, 1.2, 1.2] }, { "mesh": 0, "translation": [0, 0.5, 0.5], "scale": [1, 0.5, 1] } ],

第0个node是父节点,有一个子节点。父节点与子节点均带有模型变换,其中,父子节点均有scale变换。

GLTF的变换共有2种。一种是综合的matrix变换,另一种是分开的translation, rotationscale变换。两种不能同时使用。并且,在实现动画时,不能使用matrix变换。

上级的模型变换必须传递给下级的模型变换。并且,在上级的模型变换发生改变时,下级的模型变换也应随之而改变。

最顶层的节点可以是带有children的复合节点,也可以是单一的mesh节点。

基于这些规则,导致了有较多的分支情况。

鉴此,这节的客户端引用了新版的GLTFHierarchyNodes-v1.1.js文件。主要有两个方法做了较大的改动。

processMeshNode(meshWrapper, parentTransforms) { this.checkParentTransforms(meshWrapper, parentTransforms); if (meshWrapper.hasOwnProperty('children')) { for (let childMesh of meshWrapper.children) { this.processMeshNode(childMesh, meshWrapper.transforms); } } else if (meshWrapper.hasOwnProperty('mesh')) { let frameWorkMesh; if (meshWrapper.mesh.primitives[0].material.pbrMetallicRoughness.hasOwnProperty('baseColorTexture')) { frameWorkMesh = this.getTextureMesh(meshWrapper.mesh); } else { frameWorkMesh = this.getSolidMesh(meshWrapper.mesh); } meshWrapper.frameWorkMesh = frameWorkMesh; if (meshWrapper.transforms.matrix) { mat4.mul(frameWorkMesh.modelMatrix, frameWorkMesh.modelMatrix, meshWrapper.transforms.matrix); } else { if (meshWrapper.transforms.trs.translation) { mat4.translate(frameWorkMesh.modelMatrix, frameWorkMesh.modelMatrix, meshWrapper.transforms.trs.translation); } if (meshWrapper.transforms.trs.rotation) { let rotateMatrix = mat4.create(); mat4.fromQuat(rotateMatrix, meshWrapper.transforms.trs.rotation); mat4.mul(frameWorkMesh.modelMatrix, frameWorkMesh.modelMatrix, rotateMatrix); } if (meshWrapper.transforms.trs.scale) { mat4.scale(frameWorkMesh.modelMatrix, frameWorkMesh.modelMatrix, meshWrapper.transforms.trs.scale); } } this.gltfMeshWrappers.push(meshWrapper); } else { throw new Error(`Error! It might not be a mesh node!`); } } checkParentTransforms(meshWrapper, parentTransforms) { // the top level if (parentTransforms === null || parentTransforms === undefined) { meshWrapper.transforms = { matrix: null, trs: { translation: null, rotation: null, scale: null } }; // no children's top if (meshWrapper.matrix) { meshWrapper.transforms.matrix = meshWrapper.matrix; } else { if (meshWrapper.translation) { meshWrapper.transforms.trs.translation = meshWrapper.translation; } if (meshWrapper.rotation) { meshWrapper.transforms.trs.rotation = meshWrapper.rotation; } if (meshWrapper.scale) { meshWrapper.transforms.trs.scale = meshWrapper.scale; } } } else { // children level if (parentTransforms.matrix) { meshWrapper.transforms = {}; if (!meshWrapper.matrix) { meshWrapper.transforms.matrix = parentTransforms.matrix; } else { meshWrapper.transforms.matrix = meshWrapper.matrix; mat4.mul(meshWrapper.transforms.matrix, meshWrapper.transforms.matrix, parentTransforms.matrix); } } else { if (meshWrapper.matrix) { meshWrapper.transforms.matrix = meshWrapper.matrix; } } if (parentTransforms.trs.translation) { meshWrapper.transforms ??= {}; meshWrapper.transforms.trs ??= {}; if (!meshWrapper.translation) { meshWrapper.transforms.trs.translation = parentTransforms.trs.translation; } else { let parentVec = vec3.fromValues(...(parentTransforms.trs.translation)); let childVec = vec3.fromValues(...(meshWrapper.translation)); vec3.add(childVec, childVec, parentVec); meshWrapper.transforms.trs.translation = Array.from(childVec); } } else { if (meshWrapper.translation) { meshWrapper.transforms ??= {}; meshWrapper.transforms.trs ??= {}; meshWrapper.transforms.trs.translation = meshWrapper.translation; } } if (parentTransforms.trs.rotation) { meshWrapper.transforms ??= {}; meshWrapper.transforms.trs ??= {}; if (!meshWrapper.rotation) { meshWrapper.transforms.trs.rotation = parentTransforms.trs.rotation; } else { meshWrapper.transforms.trs.rotation = meshWrapper.rotation; quat.mul(meshWrapper.transforms.trs.rotation, meshWrapper.transforms.trs.rotation, parentTransforms.trs.rotation); } } else { if (meshWrapper.rotation) { meshWrapper.transforms ??= {}; meshWrapper.transforms.trs ??= {}; meshWrapper.transforms.trs.rotation = meshWrapper.rotation; } } if (parentTransforms.trs.scale) { meshWrapper.transforms ??= {}; meshWrapper.transforms.trs ??= {}; if (!meshWrapper.scale) { meshWrapper.transforms.trs.scale = parentTransforms.trs.scale; } else { let parentVec = vec3.fromValues(...(parentTransforms.trs.scale)); let childVec = vec3.fromValues(...(meshWrapper.scale)); vec3.mul(childVec, childVec, parentVec); meshWrapper.transforms.trs.scale = Array.from(childVec); } } else { if (meshWrapper.scale) { meshWrapper.transforms ??= {}; meshWrapper.transforms.trs ??= {}; meshWrapper.transforms.trs.scale = meshWrapper.scale; } } } }

processMeshNode主要负责遍历层级节点、创建应用框架的Mesh实例并应用可能存在的模型变换。

对于各个节点,我们保留该节点下各个模型变换的原有属性,但将逐级应用的模型变换都保存在各个节点的transforms属性中。这样即可提前计算变换结果,又可保留原来变换属性值。而在以后当相应节点的模型变换发生改变时,也可方便地新的模型变换传递给当前节点的子节点。

checkParentTransforms主要负责将各级节点与上级节点的模型变换进行整合。因为存在4种不同的模型变换,且每一种均需要分别判断上级节点与本级节点是否有相应的属性并作出相应的处理,因此代码显得有些长。

在这里我们在顶层节点不做默认的设置,否则每个下级节点将被迫整合4种模型变换,即使有些根本就不存在,从而造成效能上的损耗。

4种模型变换的整合都各有不同。matrix可使用mat4相乘,translation可使用vec3相加,rotation可使用quat相乘,而scale可使用vec3相乘(注意不是dot点积或cross叉积)。

最后,为解决当改变加载不同文件时需要重置视口相机的问题,客户端将相应的代码提炼为单独的函数以供重复使用。

app.doInInitViewports((cameraManager, viewportManager) => { resetViewport(cameraManager, viewportManager); }); function resetViewport(cameraManager, viewportManager, fileURL) { cameraManager.eyeDist = 7; viewportManager.useLayout(VIEWPORT_LAYOUT.Single); viewportManager.activeViewport.camera.setAzimuth(10); viewportManager.activeViewport.camera.setElevation(10); switch (fileURL) { case '/tutorials/webgl/gltf/examples/BarramundiFish/glTF/BarramundiFish.gltf': viewportManager.activeViewport.camera.setPosition([0, 0.1, 1.5]); viewportManager.activeViewport.camera.setAzimuth(30); viewportManager.activeViewport.camera.setElevation(10); break; case '/tutorials/webgl/gltf/examples/BoomBox/glTF/BoomBox.gltf': viewportManager.activeViewport.camera.setPosition([0, 0, 0.07]); break; case '/tutorials/webgl/gltf/examples/WaterBottle/glTF/WaterBottle.gltf': viewportManager.activeViewport.camera.setPosition([0, 0, 0.7]); break; case '/tutorials/webgl/gltf/examples/Corset/glTF/Corset.gltf': viewportManager.activeViewport.camera.setPosition([0, 0, 0.3]); break; default: viewportManager.activeViewport.camera.setPosition([0, 0, 7]); break; } } function initDatGui(glTFObj) { gui = new GUI({name: 'My GUI'}); let filesFolder = gui.addFolder('Files'); filesFolder.open(); let controller = filesFolder.add(uiObj, 'file', filesMap); controller.onChange((fileURL) => { let loader = new GLTFLoader(); loader.loadFile(fileURL) .then(glTFObj => { gui.removeFolder(sceneFolder); initSceneFolder(glTFObj); let scene = Scene.instance; scene.objects = []; resetViewport(CameraManager.instance, ViewportManager.instance, fileURL); showScene(scene, glTFObj); }); }); initSceneFolder(glTFObj); }

运行应用。观察上下级节点的模型变换是否正确地进行了传递。

一个通用的 glTFObj 树形控件

一个GLTF文件的内容可能较多,因此,即使我们直接打开该文件的JSON内容来看,也很容易迷失。而树形控件在保留了原始层级的同时,可以很好地隐藏不必要的细节,因此,如果有一个能查看GLTF文件的树形控件,将对我们起到非常大的帮助。

树形控件的实现比较简单,我使用CSS 3,并利用HTML 5中现成的details控件,实现了一个树形控件。查看演示效果

而若要将树形控件应用到GLTF文件上面,其复杂的层级引用关系,给需要递归遍历的场合带来了不小的困难。例如,属性中含有数组,而数组中又含有属性,且各种类型不同的数据可以需要不同的处理方式。下面我们逐步克服这些问题。

第一步,递归遍历一个对象的初始代码如下:

const NO_RECURSIVE_TYPES = ['number', 'string', 'boolean']; let obj = glTFObj; traverseTree(obj); function traverseTree(objOrArr, parent) { if (Array.isArray(objOrArr)) { processArray(objOrArr, parent); } else if (objOrArr instanceof Object) { processObject(objOrArr, parent); } else if (NO_RECURSIVE_TYPES.includes(typeof(objOrArr))) { processSimpleData(objOrArr, parent); } else { alert("Unknown data type!"); alert(objOrArr); throw new Error('Unknown data type!'); } }

对于数组及Object的实例,则递归调用;而对于数值、字符串及布尔型的数据,直接处理,不遍历。这里采用了防御性编程策略,上面我们手工列举了所想得到的情况,若有超出我们意料之外的遗漏之处,则抛出异常。这样就不容易出现bug。

而各种数据类型的判断方式,均使用了不同的方式。因为数组也是Object的一个实例,因此需先调用ArrayisArray静态方法来先判断是否数组,然后再调用instanceof操作符来判断是否对象实例,最后才判断是否3种特殊的数据类型。这样,如果不抛出异常,则说明已经遍历了所有的节点。

在大部分情况下,我们都需要处理父级节点的数据,因此参数parent可满足此需求。

第二步,委派各项任务至相应函数。

function traverseTree(objOrArr, parent) { if (Array.isArray(objOrArr)) { processArray(objOrArr, parent); } else if (objOrArr instanceof Object) { processObject(objOrArr, parent); } else if (NO_RECURSIVE_TYPES.includes(typeof(objOrArr))) { processSimpleData(objOrArr, parent); } else { alert("Unknown data type!"); alert(objOrArr); throw new Error('Unknown data type!'); } } function processArray(arr, parent) { arr.forEach(element => { traverseTree(element, arr); }); } function processObject(obj, parent) { for (let propName in obj) { traverseTree(obj[propName], obj); } } function processSimpleData(value, parent) { //console.log(value); }

第三步,保持负责递归遍历的traverseTree函数不变,根据需求,完善、细化各个具体负责处理特定类型的函数即可。例如:

function processArray(arr, parent) { if (isArrayAllNumbers(arr)) { arr.forEach((item, index) => { console.log(item); }); } else { arr.forEach((element, index) => { this.traverseTree(element, arr); }); } } function processObject(obj, parent) { for (let propName in obj) { let nodeValue = obj[propName]; if (NO_RECURSIVE_TYPES.includes(typeof(nodeValue))) { this.processSimpleData(nodeValue, propName, obj); } else { this.traverseTree(obj[propName], obj); } } } function processSimpleData(value, propName, parent) { if (typeof(value) === 'string') { } else { } if (propName !== undefined || propName !== null) { } } function isArrayAllNumbers(arr) { return arr.every((item) => typeof(item) === "number"); }

这种从上到下逐步精化的方式,极大方便了以后的代码维护任务。

运行应用

下面是动态生成图像的版本(已自动生成scenescenes属性)。

运行应用

TextureCoordinateTest.gltfToyCarscenes属性有较多变化。

参考资源

  1. glTF home
  2. glTF 2.0 Specification