3D骨骼系统的几种用例的配置方案

我前面已经总结了两篇文章来研究3D骨骼系统的底层实现原理运作机制。本文来探索一下3D骨骼系统的实际应用问题。这里提出几种实际的应用情况与3D骨骼系统的配置方案。

对所有的实际3D骨骼应用情况,我们分两种情况,第一,骨骼与被控对象位置重和的情况。第二,骨骼与被控对象分离的情况。第一种情况对应着three.js系统中skinnedMesh.bindMode === 'attached', 第二种情况对应skinnedMesh.bindMode === 'detached'。看下图示例:

其中,红色的线条代表骨骼,黑色的代表SkinnedMesh。在attached模式下,被控对象通过bindMatrix移动到骨骼所在位置,然后骨骼的运动直接控制几何顶点的运动。在实际应用中为了实现这种方案,我们有两种方案。

  1. 把受控对象的位置直接调整到骨骼所在的位置,然后只需要运行skinnedMesh.bind(skeleton)绑定起来即可。或者先把受控对象初始位置摆好,然后再配置好骨骼的位置来匹配这些受控对象。
  2. 先把骨骼摆好,然后每一个受控对象通过配置一个bindMatrix,将受控对象移动到骨骼的相应位置。此时,需要skinnedMesh.bind(skeleton, bindMatrix)

在有的情况,被控对象与骨骼在位置上是不直接影响的。我们希望骨骼的运动在跨越一段空间后再同步的去影响受控对象。此时就需要detached模式了。如上图右边所示,如果我们希望骨骼绕自己转30度,受控对象也绕自己的某个点转30度。假设,受控对象的该点到骨骼的转移矩阵为M,那么我们就需要这样来配置骨骼系统了:

skinnedMesh.bind(skeleton, M)
skinnedMesh.bindMode = 'detached'

需要注意的是,这里的M是不考虑skinnedMesh的matrixWorld在内的转移矩阵。detached模式下,matrixWorld是自由的,能够完全决定skinnedMesh所在的位置。

detach的绑定模式下,可以轻松的实现一个骨架控制多个处于不同位置的受控对象做同步运动。

深度解析3D骨骼系统中骨骼运动对几何体顶点运动的影响

前文中,我已经总结了3D骨骼系统的底层运作原理。现在我将更进一步解析骨骼的运动对几何体顶点运动的影响。three.js实现骨骼控制的代码中引用了一个bindMatrix变量,这个bindMatrix在很多应用的场景下,我们都不用管它,因为在默认的情况下,bindMatrix等于初始化时与之对应SkinnedMesh的matrixWrold。但其实bindMatrix对骨骼运动与几何顶点运动的影响是比较大的。我们可以看下一段glsl代码:

export default /* glsl */`     
#ifdef USE_SKINNING
                     
  vec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );
                            
  vec4 skinned = vec4( 0.0 );
  skinned += boneMatX * skinVertex * skinWeight.x;
  skinned += boneMatY * skinVertex * skinWeight.y;
  skinned += boneMatZ * skinVertex * skinWeight.z;
  skinned += boneMatW * skinVertex * skinWeight.w;
                                                        
  transformed = ( bindMatrixInverse * skinned ).xyz;
                                                                               
#endif
`;

在对几何顶点应用骨骼转移矩阵的时候,这里首先把顶点通过bindMatrix矩阵来转移。要解释这样做对结果的影响,我需要借助以下图来表达:

其中蓝色是骨骼bone,红点是没有通过bindMatrix转移的点,绿点是通过bindMatrix转移后的点。我们假设bone, 做了一个相对自己所在的点的旋转运动,并转过了alpha个角度。那么,按照图示,此运动对红点所的影响向量为a,对绿点影响向量为b。注意了,这里判断bone对几何点的影响方式可以看成这样:1.首先把几何点看成与bone的本地坐标系的相对位置是固定不变的。2. 当bone坐标系发生移动后,几何点做相同的运动。这里的相同是指相对全局坐标系的相同运动。例如bone绕自己转了alpha个角度,那么相对于全局坐标系,bone是做了绕bone所在坐标点做了alpha角度的转动。很显然,a和b是不一样的,bindMatrix直接影响了骨骼运动对几何顶点的影响值。

3D骨骼系统中,对几何顶点的变化顺序如下:

  1. 将所有顶点按照bindMatrix的矩阵数据做变化。
  2. 接着,顶点数据按照每个骨骼在全局坐标系下相对初始化是的变化矩阵做相应的权值变化。
  3. 随后,顶点按照bindMatrixInverse数据做变化。
  4. 最后顶点按照其自身SkinnedMesh的matrixWorld做变化

这里,需要特别注意的是,bindMatrixInverse不一定就是bindMatrix的逆。它由SkinnedMesh的bindMode来控制。bindMode为'attached'的时候,bindMatrixInverse是matrixWorld的逆,bindMode为'detached'的时候,bindMatrixInverse才是bindMatrix的逆。所以根据bindMode的不同值,上述的顺序可以简化表达如下:

bindMode等于'attached'时:

  1. 将所有顶点按照bindMatrix的矩阵数据做变化。
  2. 接着,顶点数据按照每个骨骼在全局坐标系下相对初始化是的变化矩阵做相应的权值变化。

这里的3,4步骤可以被省略掉,因为bindMatrixInverse一定是matrixWorld的逆,它们互相抵消。

bindMode等于'detached'时:

  1. 所有顶点做一个受bone权值影响的变化,相应bone对顶点的影响向量由顶点经过bindMatrix位置变化后,相对bone的位置所决定。
  2. 顶点按照其自身SkinnedMesh的matrixWorld做变化

这里,因为bindMatrixInverse与bindMatrix互逆,bindMatrixInverse能抵消掉顶点按照bindMatrix的移动,但时保留了顶点按照骨骼权值的运动影响。

3D骨骼模型的底层原理

3D骨骼模型在计算机图形学的某些应用上有很重要的地位,尤其是需要用到动画或者变形的3D模型。我们平时能接触到的各种图形引擎都实现了对3D骨骼模型的支持。本文以three.js为例,介绍一下3D骨骼模型实现的底层原理。

为了实现骨骼3D模型的系统,three.js提供了3个对象来达到目的 ———SkinnedMesh, Bone, Skeleton。对于整个骨骼体系而言,这3个对象并不是平等的关系,他们是从属关系。其中Skeleton在整个3D骨骼体系中起到一个总体的架构作用,无论是SkinnedMesh还是Bone都可以看作是Skeleton的组成部分。在three.js中,他们以不同的形式来实现与Skeleton的关系。

Skeleton定义了一个模型的所有关节结构。每一个skeleton的关节可以理解为一个点,这个点拥有记录空间位姿的所有数据,此外这个关节还拥有一个parent和children的拓扑结点的结构。大体来说,在three.js中,这个关节点基本上等价于一个Object3D。而这样一个关节点的载体就是Bone。因此,我们可以将Skeleton理解为Bone的容器,它管理着这些Bone的结构并且记录它们的初始位姿。

这里,很有意思的一点。Skeleton是通过记录那些Bone的初始矩阵的逆来记录它们的初始位置。为何如此?假设每个Bone的初始矩阵为m0,m0的逆为m0-1,Bone在经过一系列变化之后的矩阵为m1,而这一系列的变化矩阵为mt,则: mt*m0 = m1,那么mt = m1 * m0-1。如此我们调整完每一个Bone时,都能轻松的通过m0-1和矩阵的当前的数据m1来求得Bone的转移矩阵。

那么,拿到了转移矩阵后要怎么办?这就要提到SkinnedMesh了。不同于一般的Mesh,最重要的一点,SkinnedMesh的geometry中除了position, normal 等这些常见属性外,它还有skinIndex和skinWeight这两个属性。

每一个SkinnedMesh都可以与Skeleton绑定,而SkinnedMesh中geometry的skinIndex是一个4元数组,其中每一个元素是指向4个bone的指引。然后skinWeight也是一个4元数组,不过这里面每一个元素是指每一个相对应bone的影响权重。

这样skinIndex和skinWeight就建立了Skeleton中bone对geometry中position的影响。请看下面一段来自three.js中glsl的代码片段:

export default /* glsl */`     
#ifdef USE_SKINNING
                     
  vec4 skinVertex = bindMatrix * vec4( transformed, 1.0 );
                            
  vec4 skinned = vec4( 0.0 );
  skinned += boneMatX * skinVertex * skinWeight.x;
  skinned += boneMatY * skinVertex * skinWeight.y;
  skinned += boneMatZ * skinVertex * skinWeight.z;
  skinned += boneMatW * skinVertex * skinWeight.w;
                                                        
  transformed = ( bindMatrixInverse * skinned ).xyz;
                                                                               
#endif
`;


export default /* glsl */`     
#ifdef USE_SKINNING
                     
  mat4 boneMatX = getBoneMatrix( skinIndex.x );            
  mat4 boneMatY = getBoneMatrix( skinIndex.y );
  mat4 boneMatZ = getBoneMatrix( skinIndex.z );
  mat4 boneMatW = getBoneMatrix( skinIndex.w );    
                                                   
#endif                                             
`;     

代码中,transformed最后用来计算gl_Position的值。我们可以清晰的看到skinIndex和skinWeight是如何影响transformed的值。getBoneMatrix函数是通过指引来读取与SkinnedMesh所绑定的Skeleton中Bone的相对转移矩阵。

SkinnedMesh是最后被显卡渲染的对象,整个骨骼体系,本质上可以这么理解:3D骨骼体系建立Bone对SkinnedMesh中几何顶点的权值影响关系。而由于这层关系是通过显卡应用建立起来的,因此Bone的位姿值在变化后,实现的几何变形会非常的快速。

如果,放到three.js这里,这句话可以更精确点说成:threejs的3D骨骼体系建立了最多支持4个骨骼位姿数据对SkinnedMesh中几何顶点的权值影响关系。

通过上面的解释,我们也能理解,这个关系是通过Skeleton建立起来的。

threejs,blender等一系列开源系统对obj,mtl格式文件的支持问题

obj格式是WaveFront公司推出的一种3d模型格式,该文件通过点,线,自由参数曲面等信息来描述mesh模型。也算是比较通用的3d交互格式。但是在通过blender, threejs等系统导入此格式时却需要特别注意一个点。那就是对参数曲面的支持问题。

大部分的开源系统是不支持参数曲面的。尤其要注意以下几点:

  • 几何体的顶点(v),法线(vt),纹理点(vt)是支持的。
  • 空间参数点 (vp) 是不支持的.
  • 自由曲线和曲面是不支持的 (cstype, deg, bmat, step, curv, curv2, surf, parm, trim, hole, scrv, sp, end, con).
  • 面 (f) 是支持的, 但是,点 (p) 和线 (l) 是不支持的.
  • 面的声明必须使用凸多边形,例如三角形和四边形。
  • 面的声明用点,必须是顺时针的。
  • 分组名称 (g) 是支持的, 但是所有位于g后面的字符会被解析成一个名字.
  • call 和 csh 是 不支持的.
  • 对象名称 (o) 是 不支持的.
  • 第二版本的补丁和曲线是 不支持的 (bsp, bzp, cdc, cdp, res).
  • bevel, c_interp, d_interp, lod, shadow_obj, trace_obj, ctech 和 stech 是 不支持的
  • 材质库 (mtllib)  是支持的。
  • 材质名称是支持的 (usemtl) ,但仅仅支持ascii编码的名称。

因此,在通过软件导出便于第三方系统解析的obj时,需要对导出的几何类型做一些限制。例如在配置导出曲线选择nurbs还是polyline时,都需要选择polyline。

结点数量对threejs性能的影响

结点数量对threejs性能的影响是非常大的。它远超过顶点数对threejs渲染的影响,因为结点数多,意味这threejs中的group特别多。那么threejs中对group的处理其实是在javascript中的代码运行的,这非常影响threejs的运行效率。

因此,一个提升threejs渲染性能的关键是减少3D对象的分组量。或者,即便原始数据是分组了,也要尽可能的合并。要知道2000个结点的影响远超20万个顶点对性能的影响!

threejs 的LOD用法tips

LOD对象,添加对象的addLevel函数,具备以下特性:

lod.addLevel(obj, distance)

  1. distance的意义在与:“摄像头与本对象的距离小于此指定数值时,使用此指定对象显示”
  2. 在所有level中的最大距离的那个对象所对应的distance数值没有意义,此对象为默认对象,当摄像头距离与此对象距离大于level中最大的距离时,也是显示该对象。

threejs阴影的渲染机制

理解threejs阴影的渲染机制,对控制阴影的产生性能可以有很大的帮助。本文将对threejs阴影的形成做个简要概述。

threejs中阴影的形成分两步,首先threejs会渲染出针对castShadow和receiveShadow的设定,对每个receiveShadow的面生成一个阴影纹理,然后再把这个纹理给贴在该面上。给每个面贴纹理需要一定计算量,给每次面更新纹理也需要计算量。这两个计算量的存在,有时候能很大幅度的影响一个场景在低端机器上的渲染性能。

因此,threejs的WebglRenderer特别的推出了一个属性renderer.shadowMap.autoUpdate。我们可以给这个属性配置为false从而关闭每次移动摄像头或者添加无关紧要的对象等操作而导致的shadowMap的更新,从而避免上文中提到的第一步的运算量。

正如,下文对阴影提到的问题一样:

If your scene is static, only update the shadow map when something changes, rather than every frame

Use a CameraHelper to visualize the shadow camera’s viewing frustum

Remember that point light shadows are more expensive than other shadow types since they must render six times (once in each direction), compared with a single time for DirectionalLight and SpotLight shadows

While we’re on the topic of PointLight shadows, note that the CameraHelper only visualizes one out of six of the shadow directions when used to visualize point light shadows. It’s still useful, but you’ll need to use your imagination for the other 5 directions

threejs的渲染顺序

threejs的渲染顺序有时候会对场景成像效果产生严重的影响。这一切的源头都来自于opengl的一种深度优化的工作原理。opengl会对需要渲染的对象做深度探测,也就是所谓的depthTest。当它发现需要渲染的部分被距离摄像头更近的对象遮挡住的时候,就会不再对其进行渲染。这种机制,本能的减少了gpu的工作量,优化了渲染性能。为了更好的利用这个特性,threejs会对需要渲染的非透明对象进行一次按距离摄像头从近到远的排序(近远的判断是根据object的boundingSphere属性)。如此,距离摄像头越近的对象就会越被先渲染,距离摄像头越远的对象会被后渲染,这样就能更快发现后面的对象的哪些个点会被遮住,从而避免对该点的渲染计算。然而,对于透明对象(transparent === true),threejs会按照距离摄像头从远到近的顺序来进行渲染,这样就能避免距离摄像头远的透明物体被因为depthTest的机制而取消渲染。

还有一点,就是透明的物体都是渲染在非透明的物体之后。这样做的原因无它,也就是想要避免因depthTest机制的存在而导致放在透明物体后面的非透明物体没有被渲染。

那么问题来了,如果两个对象距离摄像头的距离一样呢?这确实就是很多图像显示的罪恶之源。threejs就会不知道先渲染哪个,这会导致偶发性的z-fighting。在一些个别或者任何的摄像头位置,一些像素点显示的颜色不断的在两个对象间切换,甚至是有些对象出现可视与不可视之间不断的跳变。像这种情况,通常的解决方法,有三种。第一,关闭某个对象的depthTest, 让它能无视渲染顺序,从而确保其会被渲染,而不会因为depthTest的存在而变得不可见。第二,关闭某个对象的depthWrite,让该对象的的数据不会被写入gpu的深度缓存中,从而,其他对象在做depthTest的时候,就不会发现它挡在了前面。第三,人为的设定对象的渲染顺序。每个threejs对象都会有一个renderOrder属性,我们可以人为地配置对象A的renderOrder小于B的renderOrder,这样,就算A按照前文的机制因该渲染在B之后,但由于renderOrder的原因,能而改变此规则,让A渲染在B之前。

后处理中bloom与outline的实现原理

bloom与outline是后处理中最常用的两个效果,这里对它们的实现原理做个基本的介绍。

bloom

bloom即是所谓的绽放效果,它能让3D对象产生“亮闪闪”的特效。让人感觉该3D对象能够发光,而且光线能够延伸到3D对象以外的空间。这种特效得益于三个后处理步骤。第一,通过一种机制将发光部分的计准图片从3D渲染得到的图片中选择出来(抠图)。这种方法可以有很多,例如,可以设定一个发光度阈值,对2维图片做个高通滤波,把一张图片中亮度高的像素点选择出来,以仅仅含有这些高像素点的图片为计准。也可以一开始先设定一些3D对象,先对整个场景有选择的对3D对象做可视性的设定,先对这些希望能让其发光的对象先做渲染,然后以这张图片为发光特效生成的基准。有了基准图片后,接着可以对此图片做多次的高斯模糊处理,让基准图片上的像素像四周延伸。最后,再把这张有选择性的,向四周延伸图片与正常场景的图片做一个混色合成。大功告成,最后得到了一张看起来图片中有部分在发光而且还影响到了其周围的物体。

outline

理解了bloom的机制,再来理解outline的工作机制就轻松一些了。其实它们的步骤差不多:第一,做选择。将需要描绘outline的部件选择出来,对这些部件单独做深度渲染到一张图片。第二,对这个做了深度渲染图片做边界的渲染,这可能包括边界的识别,边界的高斯模糊处理。第三,渲染一张正常的图片后,再把前一张图片与这张正常图片做混色合成。这就是outline的工作机制。

计算机3D渲染中的后处理post processing

后处理对于很多3D渲染的应用都是非常必要的,它有时候能够很大的增强图面的视觉体验,例如一些色彩的过滤,模糊,电影画面等;有时候还能实现常规渲染难以实现的功能,如绽放特效(bloom),边界检测与描绘等。

3D渲染的后处理本质,其实是2D图片的颜色处理与多张图片的定制化合成技术。其流程通常是这样的:首先通过常规的渲染将3d场景按照一种或多种指定的条件渲染出一张或者多张的2维图片;接着建立一个四边形,把这些图片的一张或者多张的数据作为纹理传递给这个四边形;这个四边形带着这些纹理数据将再次被放入gpu进行定制化的渲染;这个建立四边形,写入纹理渲染的步骤将会被重复多次;最后得到人们期望的图片。

通过这个描述,我们应该清楚,在后处理的过程中,我们主要是要利用gpu进行2维图像的合成和处理运算。也正是因为gpu比cpu更擅长做这个事,因此我们才会创建四边形,把数据传入gpu处理。否则这个图片处理的流程,放在cpu计算会更直接一些。