深入学习Three.js核心对象之(二)Geometry详解【转载】
这次分析构成模型对象的重要元素之一:Geometry(几何体)。
主要介绍:
- Geometry的属性: 基础属性与动画属性
- Geometry的方法: 基础变换、Mesh与顶点合并、点面法线、包围盒/球计算
- BufferGeometry 与 DirectGeometry(Todo)
本文所参考的Three.js版本为0.116.1
系列文章
- 深入学习Three.js核心对象之(一)Object3D
- 深入学习Three.js核心对象之(二)Geometry
- 深入学习Three.js核心对象之(三)Material
Geometry
Geometry的属性主要可以分为两类:
- 一类表示几何体的坐标、颜色、面等基础信息
- 另一类存储morph与skin的相关数据,用于动画等操作
关于方法主要包含以下几类:
- 基础变换: 几何体在模型空间发生变换时使用
- 合并计算: 顶点合并与网格(Mesh)合并
- 法线计算: 计算顶点法线、面法线、morph法线等
- 包围盒/球计算: 传入顶点数组,计算包围盒/球
属性
基础属性
- 顶点(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
参考
- 原文作者:yrq110
- 原文链接:http://yrq110.me/post/front-end/deep-in-threejs-core-objects-ii-geometry/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
WEBGL学习网 » 深入学习Three.js核心对象之(二)Geometry详解【转载】