分析构成模型对象的重要元素之一: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对象的变换方法。

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

参考