这次分析构成模型对象的重要元素之一:Geometry(几何体)。

主要介绍:

  • Geometry的属性: 基础属性与动画属性
  • Geometry的方法: 基础变换、Mesh与顶点合并、点面法线、包围盒/球计算
  • BufferGeometry 与 DirectGeometry(Todo)

本文所参考的Three.js版本为0.116.1

系列文章

Geometry

Geometry的属性主要可以分为两类:

  1. 一类表示几何体的坐标、颜色、面等基础信息
  2. 另一类存储morph与skin的相关数据,用于动画等操作

关于方法主要包含以下几类:

  1. 基础变换: 几何体在模型空间发生变换时使用
  2. 合并计算: 顶点合并与网格(Mesh)合并
  3. 法线计算: 计算顶点法线、面法线、morph法线等
  4. 包围盒/球计算: 传入顶点数组,计算包围盒/球

属性

基础属性

  • 顶点(vertices): 核心属性,表示几何体的顶点位置,在构造面及计算法线等时使用
  • 颜色(colors): 用于着色时的颜色计算
  • 面(faces): 由不同顶点组成的面,包含顶点索引、面法线、面顶点法线等数据,一般为三角面(包含三个点的索引)
  • uv(faceVertexUvs): uv层数组。其中的索引意义: geometry.faceVertexUvs[materialIndex][faceIndex][vertexIndex]

动画属性

Three中可以通过两种方式实现动画:

  • 变形动画(morph animation): 每一帧的状态由指定的顶点数组决定,在动画中应用指定顶点位置数组插值后的值
  • 骨骼蒙皮动画(bones skin animation): 每一帧的状态中蒙皮(可理解为Mesh)的顶点位置由指定的不同骨骼及它们的权重决定

morph相关属性

  • morphTargets: morph对象数组,包含名称与顶点数组数据,一般为从外部传入.
    this.morphTargets = [
      { name: "frame_1", vertices: [...] },
      { name: "frame_2", vertices: [...] },
      { name: "frame_3", vertices: [...] }
    ]
    
  • morphNormals: morph对象法线。通过computeMorphNormals()方法计算得出,后续会介绍。

真正实现morph动画还需要结合AnimationMixer与AnimationClip对象来对morph对象进行插值和其他处理。

Three的一个morph例子:demo

skin相关属性

skin相关属性用于骨骼蒙皮动画,在与SkinnedMesh共同使用时才会被用到:

  • skinIndices: 一个Vector4数组,用来表示当前点受哪些骨骼控制。Three中一个顶点最多受4根骨头控制,因此skinIndices是一个Vector4数组。
  • skinWeights: 也是一个Vector4数组,其中的数据和skinIndices数组一一对应,用来表示对应的骨骼影响该点的比重。

SkinnedMesh中的处理(仅支持BufferGeometry)

boneTransform: ( function () {
  /*
   一些变量准备...
  */
  return function ( index, target ) {
    var skeleton = this.skeleton;
    var geometry = this.geometry;
    // 获取BufferGeometry中的属性
    skinIndex.fromBufferAttribute( geometry.attributes.skinIndex, index );
    skinWeight.fromBufferAttribute( geometry.attributes.skinWeight, index );
    basePosition.fromBufferAttribute( geometry.attributes.position, index ).applyMatrix4( this.bindMatrix );
    target.set( 0, 0, 0 );
    for ( var i = 0; i < 4; i ++ ) {
      // 获取骨骼权重
      var weight = skinWeight.getComponent( i );
      if ( weight !== 0 ) {
        // 获取骨骼索引
        var boneIndex = skinIndex.getComponent( i );
        // 由于此部分个人理论较差不太确定,猜测为计算"骨骼空间"的矩阵
        matrix.multiplyMatrices( skeleton.bones[ boneIndex ].matrixWorld, skeleton.boneInverses[ boneIndex ] );
        // 在目标向量上结合基础位置、变换矩阵与权重进行计算
        target.addScaledVector( vector.copy( basePosition ).applyMatrix4( matrix ), weight );
      }
    }
    return target.applyMatrix4( this.bindMatrixInverse );
  };
}()

基础变换

Geometry变换与Object3D变换在使用上的不同点用文档中的话来说就是: Geometry变换一般是一次性操作,不要用在渲染循环中,若想用在渲染循环中执行变换请使用Object3D对象的变换方法。毕竟表示模型的Mesh对象为Object3D对象,若在渲染循环中变换Geometry的话还要重新构建Mesh,也是很耗性能的。

Geometry对象的变换采用图形学中四维齐次矩阵表示的基础变换来计算。在提供的API方法内部会根据基础变换类型得到一个变换矩阵,参与后续计算,即下面的 _m1 :

var _m1 = new Matrix4();
...
rotateX: function ( angle ) {
  _m1.makeRotationX( angle );
  this.applyMatrix4( _m1 );
  return this;
},
translate: function ( x, y, z ) {
  _m1.makeTranslation( x, y, z );
  this.applyMatrix4( _m1 );
  return this;
},
scale: function ( x, y, z ) {
  _m1.makeScale( x, y, z );
  this.applyMatrix4( _m1 );
  return this;
}

可以看到最后都会将矩阵传入一个applyMatrix4方法,这个方法是做什么的?

首先在烘焙(baking)顶点变换矩阵时,世界空间矩阵(world matrix)保持不变,而要改变几何体的顶点位置矩阵(vertices)。

其次Three中在geometry发生变换的同时,不光要计算几何体顶点位置的变化,还要考虑由于该变化引起的顶点和面的法线变换(用于光照计算等),以及包围盒/球的变化等,applyMatrix4即为当产生新变换时处理这些计算的通用方法:

applyMatrix4: function ( matrix ) {
  // 计算变换矩阵的法向矩阵
  var normalMatrix = new Matrix3().getNormalMatrix( matrix );
  // 变换顶点位置
  for ( var i = 0, il = this.vertices.length; i < il; i ++ ) {
    var vertex = this.vertices[ i ];
    vertex.applyMatrix4( matrix );
  }
  // 变换顶点及面的法线
  for ( var i = 0, il = this.faces.length; i < il; i ++ ) {
    var face = this.faces[ i ];
    face.normal.applyMatrix3( normalMatrix ).normalize();
    for ( var j = 0, jl = face.vertexNormals.length; j < jl; j ++ ) {
      face.vertexNormals[ j ].applyMatrix3( normalMatrix ).normalize();
    }
  }
  // 重新计算包围盒/球并重置标记
  if ( this.boundingBox !== null ) this.computeBoundingBox();
  if ( this.boundingSphere !== null ) this.computeBoundingSphere();
  this.verticesNeedUpdate = true;
  this.normalsNeedUpdate = true;
  return this;
}

其中原始变换矩阵用于顶点位置的变换

vertex.applyMatrix4( matrix );

而计算得到的法线变换矩阵用于点面法线的变换,变换后还需要归一化操作:

var normalMatrix = new Matrix3().getNormalMatrix( matrix );
...
face.normal.applyMatrix3( normalMatrix ).normalize();
...
face.vertexNormals[ j ].applyMatrix3( normalMatrix ).normalize();

这个normalMatrix遵循图形学中常用的法线变换计算方法,即法线变换为原始变换矩阵逆的转置。若原始变换为M,则法线变换为(M−1)T。从getNormalMatrix()的源码即可得知:

getNormalMatrix: function ( matrix4 ) {
  return this.setFromMatrix4( matrix4 ).getInverse( this ).transpose();
}

包围盒/球

Geometry的包围模型包含两种:

  • boundingBox
  • boundingSphere

两种用于碰撞检测的包围模型计算都是基于几何体顶点的计算。

首先会检测当前是否存在盒/球对象,否则创建初始的Box3D与Sphere模型。其次通过传入顶点对模型进行修正。

computeBoundingBox: function () {
  if ( this.boundingBox === null ) this.boundingBox = new Box3();
  this.boundingBox.setFromPoints( this.vertices );
},
computeBoundingSphere: function () {
  if ( this.boundingSphere === null ) this.boundingSphere = new Sphere();
  this.boundingSphere.setFromPoints( this.vertices );
}

boundingBox

包围盒的计算中会通过传入的顶点不断更新Box3D盒模型体对角线上的两个坐标(min,max),通过这两个值可确定一个唯一的三维空间长方体,并参与其他方法中的计算。

// src/math/Box3.js
setFromPoints: function ( points ) {
  this.makeEmpty();
  for ( var i = 0, il = points.length; i < il; i ++ ) {
    this.expandByPoint( points[ i ] );
  }
  return this;
},
...
expandByPoint: function ( point ) {
  // this.min与this.max为Box体对角线两端的点
  this.min.min( point );
  this.max.max( point );
  return this;
},

boundingSphere

包围球的计算中会通过传入的顶点不断更新球的中点坐标及半径。

// src/math/Sphere.js
setFromPoints: function ( points, optionalCenter ) {
  var center = this.center;
  if ( optionalCenter !== undefined ) {
    // 将传入的中点设为新的中点
    center.copy( optionalCenter );
  } else {
    // 将根据传入顶点得到的Box3D模型中点作为新的球体中点
    _box.setFromPoints( points ).getCenter( center );
  }
  // 将离中心点最远的顶点间距离作为球体半径
  var maxRadiusSq = 0;
  for ( var i = 0, il = points.length; i < il; i ++ ) {
    maxRadiusSq = Math.max( maxRadiusSq, center.distanceToSquared( points[ i ] ) );
  }
  this.radius = Math.sqrt( maxRadiusSq );
  return this;
},

合并

Geometry提供了合并相关的方法,用于网格合并与自身顶点合并。

  • mergeMesh
  • mergeVertices

mergeMesh

网格合并将当前的Geometry对象与传入的Mesh合并,会根据传入网格的geometry与matrix更新当前geometry的基础属性(顶点、颜色、面、uv)。

mergeMesh: function( mesh ) {
  if ( mesh.matrixAutoUpdate ) mesh.updateMatrix();
  // merge方法中执行具体的基础属性更新
  this.merge( mesh.geometry, mesh.matrix );
},

this.merge()方法中,对于顶点、颜色与uv属性的合并会直接进行数组合并操作,对于面会重新计算其法线。

mergeVertices

合并顶点是对于Geometry对象自身的操作。在合并顶点时会利用hashmap移除重复的顶点并在合并顶点后更新面包含的顶点。

mergeVertices: function() {
  // 利用hashmap过滤重复顶点
  for ( i = 0, il = this.vertices.length; i < il; i ++ ) {
    v = this.vertices[ i ];
    // 构建顶点key,用于检测在hashmap中是否存在
    key = Math.round( v.x * precision ) + '_' + Math.round( v.y * precision ) + '_' + Math.round( v.z * precision );
    if ( verticesMap[ key ] === undefined ) {
      verticesMap[ key ] = i;
      unique.push( this.vertices[ i ] );
      changes[ i ] = unique.length - 1;
    } else {
      changes[ i ] = changes[ verticesMap[ key ] ];
    }
  }
  // 在合并顶点后,对于包含重复顶点的表面需要被从geometry中移除
  var faceIndicesToRemove = [];
  for ( i = 0, il = this.faces.length; i < il; i ++ ) {
    face = this.faces[ i ];
    face.a = changes[ face.a ];
    face.b = changes[ face.b ];
    face.c = changes[ face.c ];
    indices = [ face.a, face.b, face.c ];
    // 若Face3对象中存在重复顶点,则需要移除
    for ( var n = 0; n < 3; n ++ ) {
      if ( indices[ n ] === indices[ ( n + 1 ) % 3 ] ) {
        faceIndicesToRemove.push( i );
        break;
      }
    }
  }
  // 根据需要移除的表面索引数组倒序删除
  for ( i = faceIndicesToRemove.length - 1; i >= 0; i -- ) {
    var idx = faceIndicesToRemove[ i ];
    this.faces.splice( idx, 1 );
    for ( j = 0, jl = this.faceVertexUvs.length; j < jl; j ++ ) {
      this.faceVertexUvs[ j ].splice( idx, 1 );
    }
  }
  // 更新为无重复点的顶点数组
  var diff = this.vertices.length - unique.length;
  this.vertices = unique;
  return diff;
}

法线计算

Geometry中提供了多个用于计算顶点与表面等法线的方法

  • 面法线: computeFaceNormals
  • 顶点法线: computeVertexNormals
  • 平顶点法线?: computeFlatVertexNormals
  • morph对象法线: computeMorphNormals

computeFaceNormals

计算所有面的法线(单位向量),将相邻两边向量的叉乘归一化后得出。

cb.subVectors( vC, vB );
ab.subVectors( vA, vB );
cb.cross( ab );
cb.normalize();

computeVertexNormals

计算所有顶点的法线(单位向量),将顶点所在表面的法线向量叠加,并进行归一化得出。

// 先计算面法线
this.computeFaceNormals();
for ( f = 0, fl = this.faces.length; f < fl; f ++ ) {
  face = this.faces[ f ];
  // 叠加在每个顶点的向量上
  vertices[ face.a ].add( face.normal );
  vertices[ face.b ].add( face.normal );
  vertices[ face.c ].add( face.normal );
}
// 全部进行归一化,即为顶点法线
for ( v = 0, vl = this.vertices.length; v < vl; v ++ ) {
  vertices[ v ].normalize();
}
// 利用计算结果更新面所包含的顶点法线数据
var vertexNormals = face.vertexNormals;
vertexNormals[ 0 ].copy( face.normal );
vertexNormals[ 1 ].copy( face.normal );
vertexNormals[ 2 ].copy( face.normal );

computeFlatVertexNormals

计算面的法线,将其作为面对象中存储的所包含的顶点法线数据(face.vertexNormals),不修改几何体本身的顶点(vertices)。

// 先计算面法线
this.computeFaceNormals();
...
// 直接将面法线作为面包含的顶点法线
var vertexNormals = face.vertexNormals;
vertexNormals[ 0 ].copy( face.normal );
vertexNormals[ 1 ].copy( face.normal );
vertexNormals[ 2 ].copy( face.normal );
...

computeMorphNormals

计算morph对象的法线,调用前面的方法结合临时几何体得到morph对象的点面法线数据。

// 缓存原始法线数据
...
face.__originalFaceNormal = face.normal.clone();
face.__originalVertexNormals[ i ] = face.vertexNormals[ i ].clone();
// 利用临时几何体计算morph对象的点面法线
var tmpGeo = new Geometry();
tmpGeo.faces = this.faces;
for ( i = 0, il = this.morphTargets.length; i < il; i ++ ) {
  if ( ! this.morphNormals[ i ] ) {
    ... // 初次访问的初始化工作
  }
  var morphNormals = this.morphNormals[ i ];
  // 将morph对象顶点赋予临时几何体
  tmpGeo.vertices = this.morphTargets[ i ].vertices;
  // 计算morph对象法线
  tmpGeo.computeFaceNormals();
  tmpGeo.computeVertexNormals();
  // 存储morph对象法线
  var faceNormal, vertexNormals;
  for ( f = 0, fl = this.faces.length; f < fl; f ++ ) {
    face = this.faces[ f ];
    morphNormals.faceNormals[ f ].copy( face.normal );
    morphNormals.vertexNormals[ f ].a.copy( face.vertexNormals[ 0 ] );
    ...
  }
}
// 恢复几何体的原始法线数据
...
face.normal = face.__originalFaceNormal;
face.vertexNormals = face.__originalVertexNormals;

BufferGeometry & DirectGeometry

Three中与Geometry相关的主要对象还有BufferGeometry与DirectGeometry。

  • todo

参考