今天继续学习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制作的。

.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'); 
- } 
webglUtils.setBuffersAndAttributes的BufferInfo。回忆 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, 
- } 
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, 
- }; 
- } 
- 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); 
- } 
- 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文件中取出数据,调用一个函数它通过这些数据创建一个矩阵,使用该矩阵。 
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); 
- } 
- } 
- } 
- 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); 
- } 
- }); 
- // 设置场景 
- 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() { 
- const gltf = await loadGLTF('resources/models/killer_whale/whale.CYCLES.gltf'); 
- { 
- "name" : "orca", 
- "primitives" : [ 
- { 
- "attributes" : { 
- "JOINTS_0" : 5, 
- "NORMAL" : 2, 
- "POSITION" : 1, 
- "TANGENT" : 3, 
- "TEXCOORD_0" : 4, 
- "WEIGHTS_0" : 6 
- }, 
- "indices" : 0 
- } 
- ] 
- } 
NORMAL和POSITION来渲染。我们在每个属性前添加了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); 
- } 
- // 编译和连接着色器,查找属性和全局变量的位置 
- 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.traverse。renderDrawables调用该节点上所有绘制对象的渲染方法,传入投影,视图矩阵,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设置全局变量,也绑定纹理,然后渲染。我们也需要一个支持蒙皮的顶点着色器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, 
- }; 
- } 
- // 给蒙皮节点添加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]; 
这显示了虎鲸的轮廓,所以我知道至少有一些数据正在发挥作用

- gl_FragColor = vec4(normalize(v_normal) * .5 + .5, 1); 
* .5 + .5调整它们到0 到 1来观察颜色。回到顶点着色器我仅仅传递法线
- v_normal = a_NORMAL; 

- v_normal = a_WEIGHTS_0.xyz * 2. - 1.; 

- v_normal = a_JOINTS_0.xyz / (u_numJoints - 1.) * 2. - 1.; 

webgl.createBufferInfoFromArrays如何计算组件数量有关。有些情况下它被忽略了特定的那个,试图猜测,并猜错了。修复bug后我移除了着色器的改动。注意如果你想使用它们,我在注释中保留它们。我想说清楚上面的代码是为了帮助说明蒙皮。它并不意味是一个成熟的蒙皮引擎。我想如果我们试图做一个可使用的引擎,我们会遇到许多我们可能需要改变的事情,但我希望这个例子可以帮助轻微揭开蒙皮的神秘面纱。再次声明,文章并非原创!这里仅供学习交流,原文链接:https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-skinning.html。如果转载请标明原文章出处!
学习交流小伙伴公众号giserYZ2SS直接留言。