中webgl解析json_WebGL蒙皮(下)

今天继续学习webgl一个重要功能:skinning(蒙皮),内容来自学习网站webglfundamentals,这里仅供学习交流,原文链接:https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-skinning.html。文章并非原创!如果转载请标明原文章出处!

前一篇我们学习的《WebGL蒙皮(上)》就是蒙皮的基础知识。写呈现蒙皮网格的代码并不困难。更困难的部分实际上是获取数据。你通常需要一些3D软件像blender/maya/3d studio max,然后要么写你自己的导出器或者找到一个导出器提供所有你需要的数据。你会看到像我们介绍的一样加载蒙皮相较于展示它会有10倍多的代码,这还不包括大约20-30倍多的代码从3D程序中导出的导出器。题外话这部分通常是人们写他们的3D引擎通常忽略的。

让我们尝试加载一个glTF文件。 glTF是为WebGL而设计的。在网上我找到了这个虎鲸文件是Junskie Pastilan制作的。

744d56f82661401448967a8869d7900c.png

glTF有两种格式。.gltf格式是一个JSON文件通常引用一个 .bin文件,这是一个二进制文件通常只包含几何体,可能包含动画数据。另一种格式是.glb二进制格式。通常是JSON和其他文件连接到一个二进制文件内,每个连接部分之间有一个短头和一个大小/类型描述。对于JavaScript,我认为.gltf格式稍微容易上手,所以让我们尝试加载它。首先我下载了.blend文件,安装blender,安装gltf导出器,blender中加载文件并导出。导出之后我用文本编辑器打开.gltf文件并浏览了一下。我用这个表来弄清楚格式。我想说明下面的代码不是一个完美的gltf加载器,只是足以展示鲸鱼的代码。我怀疑如果我们尝试不同的文件,我们会遇到需要更改的区域。首先要做的事情是加载文件。简单起见,我们使用JavaScript的async/await。首先我们写一些代码来加载.gltf 文件和它引用的文件。
  • async function loadGLTF(url) {

  • const gltf = await loadJSON(url);

  • // 加载所有gltf文件相关连的文件

  • const baseURL = new URL(url, location.href);

  • gltf.buffers = await Promise.all(gltf.buffers.map((buffer) => {

  • const url = new URL(buffer.uri, baseURL.href);

  • return loadBinary(url.href);

  • }));

  • ...

  • async function loadFile(url, typeFunc) {

  • const response = await fetch(url);

  • if (!response.ok) {

  • throw new Error(`could not load: ${url}`);

  • }

  • return await response[typeFunc]();

  • }

  • async function loadBinary(url) {

  • return loadFile(url, 'arrayBuffer');

  • }

  • async function loadJSON(url) {

  • return loadFile(url, 'json');

  • }

现在我们需要遍历数据将其连接起来。首先让我们着手于glTF如何定义一个网格。网格是图元的集合。图元实际上是渲染所需的缓冲和属性。让我们使用码少趣多文章中实现的webglUtils。我们将遍历网格,为每个网格创建一个传递给webglUtils.setBuffersAndAttributesBufferInfo。回忆 BufferInfo实际上只是属性信息,及下标如果有的话,和传递给gl.drawXXX的元素数量。举个例子一个只有位置和法线的立方体会具有如下结构的BufferInfo
  • const cubeBufferInfo = {

  • attribs: {

  • 'a_POSITION': { buffer: WebGLBuffer, type: gl.FLOAT, numComponents: 3, },

  • 'a_NORMAL': { buffer: WebGLBuffer, type: gl.FLOAT, numComponents: 3, },

  • },

  • numElements: 24,

  • indices: WebGLBuffer,

  • elementType: gl.UNSIGNED_SHORT,

  • }

所以我们将遍历每个图元生成一个像这样的BufferInfo。图元有一组属性,每个属性引用一个访问器。访问器描述是哪种数据,例如VEC3/gl.FLOAT并引用一个视图缓冲。给定一个访问器下标,我们可以编写一些代码来返回一个WebGLBuffer,其中包含加载的数据,访问器和,缓冲视图的stride。
  • // 给定一个访问器下标返回一个访问器, WebGLBuffer和一个stride

  • function getAccessorAndWebGLBuffer(gl, gltf, accessorIndex) {

  • const accessor = gltf.accessors[accessorIndex];

  • const bufferView = gltf.bufferViews[accessor.bufferView];

  • if (!bufferView.webglBuffer) {

  • const buffer = gl.createBuffer();

  • const target = bufferView.target || gl.ARRAY_BUFFER;

  • const arrayBuffer = gltf.buffers[bufferView.buffer];

  • const data = new Uint8Array(arrayBuffer, bufferView.byteOffset, bufferView.byteLength);

  • gl.bindBuffer(target, buffer);

  • gl.bufferData(target, data, gl.STATIC_DRAW);

  • bufferView.webglBuffer = buffer;

  • }

  • return {

  • accessor,

  • buffer: bufferView.webglBuffer,

  • stride: bufferView.stride || 0,

  • };

  • }

我们也需要一个将glTF访问器的type类型转换为数字的方法
  • function throwNoKey(key) {

  • throw new Error(`no key: ${key}`);

  • }

  • const accessorTypeToNumComponentsMap = {

  • 'SCALAR': 1,

  • 'VEC2': 2,

  • 'VEC3': 3,

  • 'VEC4': 4,

  • 'MAT2': 4,

  • 'MAT3': 9,

  • 'MAT4': 16,

  • };

  • function accessorTypeToNumComponents(type) {

  • return accessorTypeToNumComponentsMap[type] || throwNoKey(type);

  • }

现在我们已经创建了这些函数,我们可以使用他们来设置网格注意:glTF文件可以定义材质,但是导出器并没有导出任何材质到文件内,即使已经勾选了导出材质的选项。我只能猜测在blender中导出器不处理任何材质。我们会使用默认材质如果文件中没有材质的话。因为这个文件中没有任何材质,这里没有使用glTF材质的代码。
  • const defaultMaterial = {

  • uniforms: {

  • u_diffuse: [.5, .8, 1, 1],

  • },

  • };

  • // 设置网格

  • gltf.meshes.forEach((mesh) => {

  • mesh.primitives.forEach((primitive) => {

  • const attribs = {};

  • let numElements;

  • for (const [attribName, index] of Object.entries(primitive.attributes)) {

  • const {accessor, buffer, stride} = getAccessorAndWebGLBuffer(gl, gltf, index);

  • numElements = accessor.count;

  • attribs[`a_${attribName}`] = {

  • buffer,

  • type: accessor.componentType,

  • numComponents: accessorTypeToNumComponents(accessor.type),

  • stride,

  • offset: accessor.byteOffset | 0,

  • };

  • }

  • const bufferInfo = {

  • attribs,

  • numElements,

  • };

  • if (primitive.indices !== undefined) {

  • const {accessor, buffer} = getAccessorAndWebGLBuffer(gl, gltf, primitive.indices);

  • bufferInfo.numElements = accessor.count;

  • bufferInfo.indices = buffer;

  • bufferInfo.elementType = accessor.componentType;

  • }

  • primitive.bufferInfo = bufferInfo;

  • // 存储图元的材质信息

  • primitive.material = gltf.materials && gltf.materials[primitive.material] || defaultMaterial;

  • });

  • });

现在每个图元都有一个bufferInfo和一个material属性。对于蒙皮,我们通常需要某种场景图。我们在场景图的文章中创建了一个场景图,所以让我们使用那个。
  • class TRS {

  • constructor(position = [0, 0, 0], rotation = [0, 0, 0, 1], scale = [1, 1, 1]) {

  • this.position = position;

  • this.rotation = rotation;

  • this.scale = scale;

  • }

  • getMatrix(dst) {

  • dst = dst || new Float32Array(16);

  • m4.compose(this.position, this.rotation, this.scale, dst);

  • return dst;

  • }

  • }

  • class Node {

  • constructor(source, name) {

  • this.name = name;

  • this.source = source;

  • this.parent = null;

  • this.children = [];

  • this.localMatrix = m4.identity();

  • this.worldMatrix = m4.identity();

  • this.drawables = [];

  • }

  • setParent(parent) {

  • if (this.parent) {

  • this.parent._removeChild(this);

  • this.parent = null;

  • }

  • if (parent) {

  • parent._addChild(this);

  • this.parent = parent;

  • }

  • }

  • updateWorldMatrix(parentWorldMatrix) {

  • const source = this.source;

  • if (source) {

  • source.getMatrix(this.localMatrix);

  • }

  • if (parentWorldMatrix) {

  • // 一个矩阵传入,所以做数学运算

  • m4.multiply(parentWorldMatrix, this.localMatrix, this.worldMatrix);

  • } else {

  • // 没有矩阵传入,所以只是拷贝局部矩阵到世界矩阵

  • m4.copy(this.localMatrix, this.worldMatrix);

  • }

  • // 现在处理所有子

  • const worldMatrix = this.worldMatrix;

  • for (const child of this.children) {

  • child.updateWorldMatrix(worldMatrix);

  • }

  • }

  • traverse(fn) {

  • fn(this);

  • for (const child of this.children) {

  • child.traverse(fn);

  • }

  • }

  • _addChild(child) {

  • this.children.push(child);

  • }

  • _removeChild(child) {

  • const ndx = this.children.indexOf(child);

  • this.children.splice(ndx, 1);

  • }

  • }

相较于场景图文章中的代码有一些值的注意的变化。
  • 此代码使用ES6的class特性。

    使用class语法比定义类的旧方法要好得多。

  • 我们给Node添加了要绘制的数组

    这将列出从此节点要绘制的的物体。我们会用类的实例实际上来绘制。这个方法我们通常可以用不同的类绘制不同的物体。

    注意:我不确定在Node里添加一个要绘制的数组是最好的方法。我觉得场景图本身应该可能不包含要绘制的物体。需要绘制的东西可改为图中节点的引用来获取数据。要绘制物体的方法比较常见所以让我们开始使用。

  • 我们增加了一个traverse方法。

    它用当前节点调用传入的函数,并对子节点递归执行。

  • TRS类使用四元数进行旋转

    我们并没有介绍过四元数,说实话我不认为我非常理解足以解释他们。幸运的是,我们用它们并不需要知道他们如何工作。我们只是从gltf文件中取出数据,调用一个函数它通过这些数据创建一个矩阵,使用该矩阵。

glTF文件中的节点数据存储为数组。我们会转换glTF文件中的节点数据为Node实例。我们存储节点数据的旧数组为origNodes,我们稍后会需要用它。
  • const origNodes = gltf.nodes;

  • gltf.nodes = gltf.nodes.map((n) => {

  • const {name, skin, mesh, translation, rotation, scale} = n;

  • const trs = new TRS(translation, rotation, scale);

  • const node = new Node(trs, name);

  • const realMesh = gltf.meshes[mesh];

  • if (realMesh) {

  • node.drawables.push(new MeshRenderer(realMesh));

  • }

  • return node;

  • });

上面我们为每个节点创建一个TRS实例,一个Node实例,我们查找之前设置的网格数据,如果有mesh属性的话,创建一个 MeshRenderer来绘制它。让我们来创建MeshRenderer。它只是码少趣多文章中渲染单个模型代码的封装。它所做的就是存一个对于网格的引用,然后为每个图元设置程序,属性和全局变量,最终通过webglUtils.drawBufferInfo调用gl.drawArrays或者 gl.drawElements;
  • class MeshRenderer {

  • constructor(mesh) {

  • this.mesh = mesh;

  • }

  • render(node, projection, view, sharedUniforms) {

  • const {mesh} = this;

  • gl.useProgram(meshProgramInfo.program);

  • for (const primitive of mesh.primitives) {

  • webglUtils.setBuffersAndAttributes(gl, meshProgramInfo, primitive.bufferInfo);

  • webglUtils.setUniforms(meshProgramInfo, {

  • u_projection: projection,

  • u_view: view,

  • u_world: node.worldMatrix,

  • });

  • webglUtils.setUniforms(skinProgramInfo, primitive.material.uniforms);

  • webglUtils.setUniforms(skinProgramInfo, sharedUniforms);

  • webglUtils.drawBufferInfo(gl, primitive.bufferInfo);

  • }

  • }

  • }

我们已经创建了节点,现在我们需要将它们实际安排到场景图中。在glTF中2步完成。首先,每个节点有一个可选的children属性,为子节点的下标数组,所以我们可以遍历所有节点为它们的子节点设定父节点。
  • function addChildren(nodes, node, childIndices) {

  • childIndices.forEach((childNdx) => {

  • const child = nodes[childNdx];

  • child.setParent(node);

  • });

  • }

  • // 将节点加入场景图

  • gltf.nodes.forEach((node, ndx) => {

  • const children = origNodes[ndx].children;

  • if (children) {

  • addChildren(gltf.nodes, node, children);

  • }

  • });

然后有一个场景的数组。一个场景有一个场景底部节点在nodes数组中下标的数组来引用这些节点。我不是很清楚为什么不简单地从单个根节点开始,但是无论如何这就是glTF文件中地内容。所以我们创建一个根节点,作为所有场景子节点的父节点。
  • // 设置场景

  • for (const scene of gltf.scenes) {

  • scene.root = new Node(new TRS(), scene.name);

  • addChildren(gltf.nodes, scene.root, scene.nodes);

  • }

  • return gltf;

  • }

我们已经完成了加载,至少只是网格部分。让我们将主函数标记为async 所以我们能使用await关键字。
  • async function main() {

我们可以像这样加载gltf文件
  • const gltf = await loadGLTF('resources/models/killer_whale/whale.CYCLES.gltf');

我们需要一个与gltf文件中的数据匹配的着色器。让我们看看gltf文件中的图元数据。
  • {

  • "name" : "orca",

  • "primitives" : [

  • {

  • "attributes" : {

  • "JOINTS_0" : 5,

  • "NORMAL" : 2,

  • "POSITION" : 1,

  • "TANGENT" : 3,

  • "TEXCOORD_0" : 4,

  • "WEIGHTS_0" : 6

  • },

  • "indices" : 0

  • }

  • ]

  • }

看一下,我们只使用NORMALPOSITION来渲染。我们在每个属性前添加了a_,因此像这样的顶点着色器应该可以工作。
  • attribute vec4 a_POSITION;

  • attribute vec3 a_NORMAL;

  • uniform mat4 u_projection;

  • uniform mat4 u_view;

  • uniform mat4 u_world;

  • varying vec3 v_normal;

  • void main() {

  • gl_Position = u_projection * u_view * u_world * a_POSITION;

  • v_normal = mat3(u_world) * a_NORMAL;

  • }

片断着色器中我们使用一个简单的平行光
  • precision mediump float;

  • varying vec3 v_normal;

  • uniform vec4 u_diffuse;

  • uniform vec3 u_lightDirection;

  • void main () {

  • vec3 normal = normalize(v_normal);

  • float light = dot(u_lightDirection, normal) * .5 + .5;

  • gl_FragColor = vec4(u_diffuse.rgb * light, u_diffuse.a);

  • }

注意我们使用了平行光文章中提到的点乘,但与此不同,这里点乘结果乘以.5并加上.5。正常平行光照,当直接面向光源时,表面100%照亮,减弱到0%当表面方向和光照垂直。这意味着远离光线的模型的1/2是黑的。通过乘以.5并加上.5,我们将点乘从-1 1转换到0 1,这意味着当完全反方向时才会是黑色。这为简单测试提供了简单并很好的照明。所以,我们需要编译和连接着色器
  • // 编译和连接着色器,查找属性和全局变量的位置

  • const meshProgramInfo = webglUtils.createProgramInfo(gl, ["meshVS", "fs"]);

接着渲染,所有和之前不同的地方是
  • const sharedUniforms = {

  • u_lightDirection: m4.normalize([-1, 3, 5]),

  • };

  • function renderDrawables(node) {

  • for(const drawable of node.drawables) {

  • drawable.render(node, projection, view, sharedUniforms);

  • }

  • }

  • for (const scene of gltf.scenes) {

  • // 更新场景中的世界矩阵。

  • scene.root.updateWorldMatrix();

  • // 遍历场景并渲染所有renderables

  • scene.root.traverse(renderDrawables);

  • }

之前遗留下来的(未在上面显示)是用于计算投影矩阵,相机矩阵,和视图矩阵的代码。接下来我们遍历每个场景,调用scene.root.updateWorldMatrix会更新场景图中所有节点的矩阵。然后我们为renderDrawables调用scene.root.traverserenderDrawables调用该节点上所有绘制对象的渲染方法,传入投影,视图矩阵,sharedUniforms包含的光照信息。打开网页看效果:http://39.106.0.97:8090/lesson/11Techniques/02-3D/04Skinning-03.html现在,这是我们处理蒙皮的工作。首先让我们创建一个代表蒙皮的类。它将管理关节列表,关节是应用于蒙皮的场景图中节点的另一个名字。它还会有绑定矩阵的逆矩阵。它会管理我们放入关节矩阵的材质。
  • class Skin {

  • constructor(joints, inverseBindMatrixData) {

  • this.joints = joints;

  • this.inverseBindMatrices = [];

  • this.jointMatrices = [];

  • // 为每个关节矩阵分配足够的空间

  • this.jointData = new Float32Array(joints.length * 16);

  • // 为每个关节和绑定逆矩阵创建视图

  • for (let i = 0; i < joints.length; ++i) {

  • this.inverseBindMatrices.push(new Float32Array(

  • inverseBindMatrixData.buffer,

  • inverseBindMatrixData.byteOffset + Float32Array.BYTES_PER_ELEMENT * 16 * i,

  • 16));

  • this.jointMatrices.push(new Float32Array(

  • this.jointData.buffer,

  • Float32Array.BYTES_PER_ELEMENT * 16 * i,

  • 16));

  • }

  • // 创建存储关节矩阵的纹理

  • this.jointTexture = gl.createTexture();

  • gl.bindTexture(gl.TEXTURE_2D, this.jointTexture);

  • gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);

  • gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);

  • gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);

  • gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);

  • }

  • update(node) {

  • const globalWorldInverse = m4.inverse(node.worldMatrix);

  • // 遍历每个关节获得当前世界矩阵

  • // 来计算绑定矩阵的逆

  • // 并在纹理中存储整个结果

  • for (let j = 0; j < this.joints.length; ++j) {

  • const joint = this.joints[j];

  • const dst = this.jointMatrices[j];

  • m4.multiply(globalWorldInverse, joint.worldMatrix, dst);

  • m4.multiply(dst, this.inverseBindMatrices[j], dst);

  • }

  • gl.bindTexture(gl.TEXTURE_2D, this.jointTexture);

  • gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 4, this.joints.length, 0,

  • gl.RGBA, gl.FLOAT, this.jointData);

  • }

  • }

MeshRenderer一样,我们制作SkinRenderer,来用Skin来渲染蒙皮网格。
  • class SkinRenderer {

  • constructor(mesh, skin) {

  • this.mesh = mesh;

  • this.skin = skin;

  • }

  • render(node, projection, view, sharedUniforms) {

  • const {skin, mesh} = this;

  • skin.update(node);

  • gl.useProgram(skinProgramInfo.program);

  • for (const primitive of mesh.primitives) {

  • webglUtils.setBuffersAndAttributes(gl, skinProgramInfo, primitive.bufferInfo);

  • webglUtils.setUniforms(skinProgramInfo, {

  • u_projection: projection,

  • u_view: view,

  • u_world: node.worldMatrix,

  • u_jointTexture: skin.jointTexture,

  • u_numJoints: skin.joints.length,

  • });

  • webglUtils.setUniforms(skinProgramInfo, primitive.material.uniforms);

  • webglUtils.setUniforms(skinProgramInfo, sharedUniforms);

  • webglUtils.drawBufferInfo(gl, primitive.bufferInfo);

  • }

  • }

  • }

你可以看到和 MeshRenderer非常类似。它有一个Skin的引用来更新所有渲染需要的矩阵。然后它后跟了渲染的标准模式,使用程序,设置属性,用webglUtils.setUniforms设置全局变量,也绑定纹理,然后渲染。我们也需要一个支持蒙皮的顶点着色器
这与我们之前介绍的蒙皮着色器几乎相同。我们重命名了属性值来匹配gltf文件中的内容。最大的不同是我们生成了一个 skinMatrix。在我们之前的蒙皮着色器,我们将位置和每一个关节/骨骼矩阵相乘,并乘以每个关节的影响权重。在这个例子中,我们代替的将矩阵和权重相乘并相加,只乘以一次位置。这产生相同的结果,但是我们可以使用skinMatrix和法线相乘,我们需要这样做否则法线会和蒙皮不匹配。还要注意在这里我们用u_world相乘。我们在Skin.update里减去了它。
  • const globalWorldInverse = m4.inverse(node.worldMatrix);

  • // 遍历每个关节,获得它当前的世界矩阵

  • // 来计算绑定矩阵的逆

  • // 并在纹理中存储整个结果

  • for (let j = 0; j < this.joints.length; ++j) {

  • const joint = this.joints[j];

  • const dst = this.jointMatrices[j];

  • m4.multiply(globalWorldInverse, joint.worldMatrix, dst);

无论你是否这样做取决于你。这样做的原因是它允许你实例化蒙皮。换句话说你可以在相同帧中在不同的地方渲染蒙皮网格。如果有很多的关节,对于一个蒙皮网格做所有的矩阵数学是非常慢的,所以你做一遍数学操作,然后你可以通过一个不同的世界矩阵将蒙皮网格重渲染在任何地方。这对于显示一群角色是有效的。不幸的是所有的角色都会是相同的姿势,所以我并不清楚这是否有用。这种情况通常出现的频率是多少? 你可以在Skin中移除乘以世界矩阵的逆并在着色器中移除乘以u_world,结果是一样的,你仅仅不能实例化 那个蒙皮网格。当然你可以多次渲染不同姿势的同一蒙皮网格。你会需要一个不同的Skin对象指向其他方向的不同节点。回到我们的加载代码,当我们创建Node实例时,如果有skin属性,我们记录它,为了能为它创建一个Skin
  • const skinNodes = [];

  • const origNodes = gltf.nodes;

  • gltf.nodes = gltf.nodes.map((n) => {

  • const {name, skin, mesh, translation, rotation, scale} = n;

  • const trs = new TRS(translation, rotation, scale);

  • const node = new Node(trs, name);

  • const realMesh = gltf.meshes[mesh];

  • if (skin !== undefined) {

  • skinNodes.push({node, mesh: realMesh, skinNdx: skin});

  • } else if (realMesh) {

  • node.drawables.push(new MeshRenderer(realMesh));

  • }

  • return node;

  • });

创建Node之后我们需要创建Skin。蒙皮通过joints数组引用节点,该数组是为关节提供矩阵的节点下标数组。蒙皮也引用一个访问器,访问器引用了保存在文件中的反向绑定姿势矩阵。
  • // 设置蒙皮

  • gltf.skins = gltf.skins.map((skin) => {

  • const joints = skin.joints.map(ndx => gltf.nodes[ndx]);

  • const {stride, array} = getAccessorTypedArrayAndStride(gl, gltf, skin.inverseBindMatrices);

  • return new Skin(joints, array);

  • });

上面的代码给定一个访问器下标,调用了getAccessorTypedArrayAndStride。我们需要提供这部分的代码。给定一个访问器,我们会返回类型化数组的正确类型视图以访问缓冲中的数据。
  • const glTypeToTypedArrayMap = {

  • '5120': Int8Array, // gl.BYTE

  • '5121': Uint8Array, // gl.UNSIGNED_BYTE

  • '5122': Int16Array, // gl.SHORT

  • '5123': Uint16Array, // gl.UNSIGNED_SHORT

  • '5124': Int32Array, // gl.INT

  • '5125': Uint32Array, // gl.UNSIGNED_INT

  • '5126': Float32Array, // gl.FLOAT

  • }

  • // 给定一个GL类型返回需要的类型

  • function glTypeToTypedArray(type) {

  • return glTypeToTypedArrayMap[type] || throwNoKey(type);

  • }

  • // 给定一个访问器下标返回访问器

  • // 和缓冲正确部分的类型化数组

  • function getAccessorTypedArrayAndStride(gl, gltf, accessorIndex) {

  • const accessor = gltf.accessors[accessorIndex];

  • const bufferView = gltf.bufferViews[accessor.bufferView];

  • const TypedArray = glTypeToTypedArray(accessor.componentType);

  • const buffer = gltf.buffers[bufferView.buffer];

  • return {

  • accessor,

  • array: new TypedArray(

  • buffer,

  • bufferView.byteOffset + (accessor.byteOffset || 0),

  • accessor.count * accessorTypeToNumComponents(accessor.type)),

  • stride: bufferView.byteStride || 0,

  • };

  • }

需要注意的是上面的代码我们用硬编码的WebGL常量制作了一个表。这是我们第一次这样做。常量不会改变,所以这是安全的。现在我们有了蒙皮,我们可以返回并将它门添加到引用它们的节点。
  • // 给蒙皮节点添加SkinRenderers

  • for (const {node, mesh, skinNdx} of skinNodes) {

  • node.drawables.push(new SkinRenderer(mesh, gltf.skins[skinNdx]));

  • }

如果我们这样渲染我们看不出任何不同。我们需要让一些节点动起来。让我们遍历Skin中的每个节点,换句话说每个关节,并在本地x轴上旋转一点点。为此,我们会存每个关节的原始本地矩阵。我们会每帧旋转一些本地矩阵,使用一个特殊的方法m4.decompose,会转换矩阵为关节的位置,旋转量,缩放量。
  • const origMatrix = new Map();

  • function animSkin(skin, a) {

  • for(let i = 0; i < skin.joints.length; ++i) {

  • const joint = skin.joints[i];

  • // 如果这个关节并没有存储矩阵

  • if (!origMatrix.has(joint)) {

  • // 为关节存储一个矩阵

  • origMatrix.set(joint, joint.source.getMatrix());

  • }

  • // 获取原始的矩阵

  • const origMatrix = origRotations.get(joint);

  • // 旋转它

  • const m = m4.xRotate(origMatrix, a);

  • // 分解回关节的位置,旋转量,缩放量

  • m4.decompose(m, joint.source.position, joint.source.rotation, joint.source.scale);

  • }

  • }

然后在渲染之前我们会调用它
  • animSkin(gltf.skins[0], Math.sin(time) * .5);

注意animSkin不是通常的做法。理想情况下,我们会加载一些艺术家制作的动画或者我们知道我们想要以某种方式在代码中操作某个特定关节。在这个例子里,我们只是看看蒙皮是否有效,这似乎是一种简单的方法。打开网页看效果:http://39.106.0.97:8090/lesson/11Techniques/02-3D/04Skinning-04.html在我们继续之前一些注意事项当我第一次尝试让它工作时,就像大多数程序一样,屏幕上没有显示的东西。所以,首先做的是在蒙皮着色器的末尾添加这一行
  • gl_Position = u_projection * u_view * a_POSITION;

在片断着色器中,我改变了它,仅仅在末尾添加这个来画纯色的
  • gl_FragColor = vec4(1, 0, 0, 1);

这将删除所有蒙皮,仅仅在原点绘制网格。我调整相机位置直到我有了一个好的视角。
  • const cameraPosition = [5, 0, 5];

  • const target = [0, 0, 0];

这显示了虎鲸的轮廓,所以我知道至少有一些数据正在发挥作用

dd633a1237c22482fc21436f4e2d0692.png

接下来我让片断着色器显示法线
  • gl_FragColor = vec4(normalize(v_normal) * .5 + .5, 1);

法线从-1 到 1,所以 * .5 + .5调整它们到0 到 1来观察颜色。

回到顶点着色器我仅仅传递法线

  • v_normal = a_NORMAL;

我可以看到这样

cff84a9f01e60cda8f0ccbac8d748547.png

我并没有觉得法线会出错,但是从我认为有效的开始,并确认它确实是有效的是很好的方法。接下来我想我应该检查权重。所有我需要做的就是像法线一样从顶点着色器传递权重
  • v_normal = a_WEIGHTS_0.xyz * 2. - 1.;

权重从0到1,但是因为片断着色器需要法线,我仅仅改变权重从-1到1这最初产生了一种混乱的颜色。一旦我发现了这个bug,我得到了这样的图像

82882e70f1a45da7e49aef2c3a1eac5f.png

它并不完全明显是正确的,但确实有道理。你希望每个骨骼最近的颜色有强烈的颜色,并且你希望在骨骼周围看到色环,因为那个区域的权重可能是1.0或者至少全部相似。由于原始图像太乱了,我也尝试显示骨骼下标
  • v_normal = a_JOINTS_0.xyz / (u_numJoints - 1.) * 2. - 1.;

下标从 0 到 骨骼数量- 1,所以上边的代码会得到-1到1的结果。当正常工作时,我得到了这样的图像

37647857c2e4ca9aa2319207d7319917.png

又一次得到了乱七八糟的颜色。上图是修复后的样子。这就是你期望看到的虎鲸的权重。每个骨骼周围的色环。这个bug和webgl.createBufferInfoFromArrays如何计算组件数量有关。有些情况下它被忽略了特定的那个,试图猜测,并猜错了。修复bug后我移除了着色器的改动。注意如果你想使用它们,我在注释中保留它们。我想说清楚上面的代码是为了帮助说明蒙皮。它并不意味是一个成熟的蒙皮引擎。我想如果我们试图做一个可使用的引擎,我们会遇到许多我们可能需要改变的事情,但我希望这个例子可以帮助轻微揭开蒙皮的神秘面纱。

再次声明,文章并非原创!这里仅供学习交流,原文链接:https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-skinning.html。如果转载请标明原文章出处!

学习交流小伙伴公众号giserYZ2SS直接留言。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/470112.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Vue权限控制——动态注册路由

需求&#xff1a;实现后台管理系统不同用户的权限控制 根据登录的用户的角色动态展示后台管理系统的左侧菜单栏的菜单列表内容&#xff0c;然后还要动态注册对应子菜单的路由 菜单列表内容应该通过后端接口返回&#xff1a; sort为1表示当前项有子菜单sort为2表示当前项没有子…

react不同环境不同配置angular_前端问题集:vue配置环境-给不同的环境配不同的打包命令...

通过vue-cli脚手架构建出一个前端项目&#xff0c;通过npm run build打包&#xff0c;发布到线上&#xff0c;但是这样做需要每次都手动修改接口地址。我们可以通过自行配置打包命令实现无需修改接口地址&#xff0c;打各个环境的包。文档结构大致如下图&#xff1a;1.找到conf…

基于Element-plus封装配置化表单组件(组件的v-model实现)

一、预备知识 1.1 组件的v-model 前面我们在input中可以使用v-model来完成双向绑定&#xff1a; 这个时候往往会非常方便&#xff0c;因为v-model默认帮助我们完成了两件事&#xff1b;v-bind:value的数据绑定 和 input的事件监听&#xff1b; 如果我们现在封装了一个组件…

Scala IDE for Eclipse的下载、安装和WordCount的初步使用(本地模式和集群模式)

不多说&#xff0c;直接上干货&#xff01; 这篇博客是&#xff0c; 是在Scala IDEA for Eclipse里maven创建scala和java代码编写环境。 Scala IDEA for Eclipse里用maven来创建scala和java项目代码环境&#xff08;图文详解&#xff09; 本博文包括&#xff1a; Scala IDE fo…

笔记本AutoCAD启动时闪退怎么办_戴尔笔记本电脑开不了机如何解决【解决方法】...

生活在互联时代下&#xff0c;我们对笔记本的需求是无处不在的&#xff0c;不管是上班族还是学生党&#xff0c;使用笔记本办公和学习给我们的生活带来很大的便捷。但使用的过程中&#xff0c;总有可能会遇到无法预料的问题。比方说 笔记本电脑 无法开机的问题&#xff0c;当…

JS高级——函数执行、作用域链内存结构图

一、JavaScript的执行过程 假如我们有下面一段代码&#xff0c;它在JavaScript中是如何被执行的呢&#xff1f; 1.1 第一步&#xff1a;初始化全局对象 js引擎会在执行代码之前&#xff0c;会在堆内存中创建一个全局对象&#xff1a;Global Object&#xff08;GO&#xff09…

JS高级——内存管理和闭包

0、预备知识 0.1 认识内存管理 不管什么样的编程语言&#xff0c;在代码的执行过程中都是需要给它分配内存的&#xff0c;不同的是某些编程语言需要我们自己手动的管理内存&#xff0c;某些编程语言会可以自动帮助我们管理内存&#xff1a; 不管以什么样的方式来管理内存&…

提取多个字段_【博客翻译】建筑物轮廓线提取以及损坏分类

原文链接原作者&#xff1a;Rohit Singh, Sandeep Kumar贡献者&#xff1a;Vinay Viswambharan, Divyansh Jha, Shivani Pathak, Daniel Wilson.翻译&#xff1a;荆雪涵在今年的 Esri 用户大会上&#xff0c;USAA 展示了基于 ArcGIS 深度学习能力&#xff0c;对 Woolsey 火灾进…

读取外部配置文件_SpringBoot外部配置、优先级及配置详解

一、外部配置及优先级SpringBoot的外部配置属性值官方给出了很多种方式&#xff0c;以便可以在不同的环境中使用相同的代码。其使用了非常特别的PropertySource命令&#xff0c;旨在允许合理的覆盖值。当然&#xff0c;如果属性值不同&#xff0c;则这些配置方式中的属性值都会…

Jquery 禁用浏览器的 后退和前进按钮

使用js,Jquery 禁用浏览器的back 和 next 按钮&#xff1a; 有时为了防治用户乱了访问顺序&#xff0c;不得不禁掉浏览器的前进后退按钮。 jQuery(document).ready(function () {if (window.history && window.history.pushState) {$(window).on(popstate, function ()…

JS数据结构与算法——冒泡排序(把大的数字依次往后放)

一、图解排序过程 注意&#xff1a;比较次数和交换次数之所以不一致&#xff0c;是因为&#xff1a;比较了并不一定就需要交换两个数字的位置&#xff0c;比如比较 1 和 2两个数字&#xff0c;由于 后者本身就比前者大&#xff0c;所以不需要交换两者的位置。 二、代码实现 三…

手机长曝光怎么设置_摄影教程丨手机如何拍摄长曝光照片,流光快门,星空银河搞起来!...

微信搜一搜定格取景框长曝光摄影可以拍摄出一些很酷的照片。这是一种非常灵活的摄影技术。它可以用来拍摄城市夜景&#xff0c;记录光绘&#xff0c;也可以拍摄水景片。甚至可以拍摄银河或捕捉星轨。其实长曝光不仅仅适合专业摄影师&#xff01;任何人都可以用手机进行慢门拍摄…

JS数据结构与算法——选择排序(把小的数字依次往前放)

一、图解排序过程 注意&#xff1a;选择排序一样是需要进行两两的比较&#xff0c;但比较过程中不进行交换&#xff0c;只有比较完成后&#xff0c;找到最小的那个数&#xff0c;才会进行交换&#xff0c;把它放到最前面。 二、代码实现 三、完整代码 <!DOCTYPE html> &…

插入模板_WordPress在文章列表和内容页插入广告

本文已同步到专业技术网站 www.sufaith.com, 该网站专注于前后端开发技术与经验分享, 包含Web开发、Nodejs、Python、Linux、IT资讯等板块.一、在文章列表插入广告文章列表模板 包括以下几个类型以及对应的主体文件:首页模板 (index.php)搜索结果页 (search.php)文章归档 (arch…

JS数据结构与算法——插入排序

一、图解排序过程 二、代码实现 三、完整代码 <!DOCTYPE html> <html lang"en"> <head><meta charset"UTF-8"><title>Title</title> </head> <body><script>// 创建列表类function ArrayList() {//…

cad完全卸载教程_CAD室内设计中厨房布置实例

▲ 点击“CAD教学”&#xff0c;获取海量学习资料和免费教程本文介绍CAD室内设计中厨房布置方法&#xff1a;1、如下图是把另一边墙砌好&#xff0c;因为不砌的话门太大的话不好。在煮菜的烟容易在烟到不胜客厅里。2、在用矩形画宽为40长为800的玻璃门。3、在把厨房的台画出来&…

CSS布局(圣杯布局、双飞翼布局、水平垂直居中)

一、圣杯布局 要求&#xff1a;三列布局&#xff1b;中间主体内容前置&#xff0c;且宽度自适应&#xff1b;两边内容定宽 好处&#xff1a;重要的内容放在文档流前面可以优先渲染 原理&#xff1a;利用相对定位、浮动、负边距布局&#xff0c;而不添加额外标签 <!DOCTYPE …

cad怎么设置线的粗细_CAD软件中怎么设置CAD线宽?

在使用CAD软件绘制CAD图纸的过程中&#xff0c;不同线宽的用处是不同的。在机械制图中&#xff0c;零部件的外轮廓就是用粗实线&#xff0c;图形内部的剖开线使用细实线。一般情况下&#xff0c;都是在绘制图形过程中先设置好图形的线宽对象&#xff0c;但也有些需要在后面的绘…

检测范围_论文检测系统的检测范围有哪些

为了能够让研究人员&#xff0c;甚至一些专业的学术专家在进行论文创作的时候&#xff0c;端正自己的学术态度&#xff0c;很多人都会要求他们在提交甚至是发表论文之前&#xff0c;附上自己的查重证明&#xff0c;只有查重率低于一定程度时&#xff0c;提交的论文才是合格的。…

2学习率调整_学习率衰减

之前我们的优化&#xff0c;主要是聚焦于对梯度下降运动方向的调整&#xff0c;而在参数迭代更新的过程中&#xff0c;除了梯度&#xff0c;还有一个重要的参数是学习率α&#xff0c;对于学习率的调整也是优化的一个重要方面。01—学习率衰减首先我们以一个例子&#xff0c;来…