【THREE.js源码】Geometry和Attribute
BufferGeometry
本文涉及的内容:
- BufferGeometry
- InstancedBufferGeometry
- BufferAttribute
- InstancedBufferAttribute
- InterleavedBuffer
- InstancedInterleavedBufferAttribute
在ThreeJs的网格体对象Mesh中,有两个最重要的概念
geometry:几何体,定义了网格对象的顶点以及各种顶点属性material:材质,决定网格体渲染的样式
可以理解为,geometry中定义了有关webgl中attribute属性中所需的数据,而material中定义的是webgl中uniform变量定义的各种属性。
BufferGeometry
BufferGeomery为Threejs中所有内置几何体类型的基类,这些内置几何体包括诸如立方体盒子、球体、圆柱体、圆锥等常见的几何体以及各种不太常见的几何体。
BufferGeometry中包含了如下属性:
name:几何体的名称type:类型,基类中为BufferGeometry,在各种内置类型中,type是对应的类型,如,三维盒子几何体的type为BoxGeometryindex:顶点索引。我们需要复用顶点来绘制三角形时,需要此项。对应的webgl内部实现是使用EBO绑定三角形索引数据,然后使用drawElements进行绘制indirect:用于WebGPU的渲染中,用于存储使用计算着色器时所需的缓冲数据attributes:用于记录内部所有的顶点属性数据。morphAttributes:一个字典数据,用于记录几何体所有的变形目标的属性数据。所谓的变形目标,就是当你想实现一个能够动态变化的物体时,记录每一个关键帧的顶点属性数据,以便于在播放动画时,能够以平滑的当时进行变换。morphTargetRelative:标识,变形目标中记录的是每个顶点的相对变换,还是使用变形目标中的数据替换原始顶点数据。groups:组划分,threejs支持将同一个几何体划分为多个组,然后不同的组的数据使用不同的材质,从而达到一个mesh的各部分渲染不同的功能。这么做的好处是,一是可以减少webgl的buffer绑定的数量,不需要频繁切换buffer数据,提高性能,二是可以将一整个网格体视为一个整体,更好控制。boundingBox:几何体顶点的包围盒boundingSphere:几何体顶点的外界包围球drawRange:绘制范围。threejs支持只渲染一个几何体的一部分,而非全部绘制。可以理解为drawArrays/drawElements中,指定绘制顶点数量的参数。userData:用户自定义数据
BufferGeometry的indirect/attributes的值,是一个Object对象,对象的键名是这个属性的名称,而值则是一个BufferAttribute对象。且,内部一些计算操作默认是对指定键名的几个属性的buffer数据进行计算的,包括
- 顶点坐标buffer:
position - 顶点法向buffer:
normal - 顶点切线buffer:
tangent - 顶点UV坐标buffer:
uv
如果没有给这些buffer赋值,则不会进行对应的计算。
在BufferGeometry中,除了其属性对象的set和get方法外,还有变换和几何计算的方法。如,applyMatrix4进行变换。BufferGeometry中的变换是针对几何体中的名为position、normal、tangent的attribute进行变换的。如果没有这几个属性,则不会变换。需要注意的是,如果在Mesh上做出各种变换,变换结果是作用在整个模型的modelMatrix上的,而如果对BufferGeometry做出变换,那么变换将会作用在buffer数据中,是实实在在地会更新几何体的顶点数据的。同样,对normal和tangent的变换,一是变换结果符合矢量的变换结果,二是变换也是会改变属性的buffer内容的。
而对于几何计算,BufferGeometry有以下几种比较常用的几何变换:
- 根据顶点计算包围盒
- 根据顶点计算包围球
- 根据顶点每个顶点的法向、切线
计算包围盒/包围球
计算顶点的包围盒和包围球比较简单,使用position的buffer中的属性进行包围盒和包围球的计算。但是要注意,如果几何体中包含变换morph,那么在计算包围盒和包围球的时候,不管变形有没有使用,也会作为一个整体计算整个的包围球和包围盒。
顶点法向的计算
几何体的顶点法向数据保存在normal属性中。在默认情况下,这个属性可以手动赋值,也可以使用computeVertexNormals,使用顶点坐标自动生成,此时会覆盖手动赋值的法向数据。
法向计算的过程:每个三角形面片由三个顶点构成,每个顶点有可能会被多个三角形复用,每个顶点的法向计算结果为,被其使用的多个三角形的法向的平均。而每个三角形的法向,可以根据三个顶点构成的两个同起点的向量来计算。
假设三角形顶点\(a,b,c\)(逆时针顺序),可以构造向量\(\vec{bc},\vec{ba}\),则三角形法向为\(\vec{bc}\times\vec{ba}\)。
在使用了EBO的几何体中,一个三角形的顶点可能会被不同的三角形复用多次,因此三个顶点的法向可能是不一样的。但是在没有使用EBO的几何体中,由于每个顶点只会被一个三角形使用,因此,一个三角形的三个顶点的法向都是一致的。
顶点切线的计算
切线的计算通常是为了使用法线贴图(normal mapping)等高级着色技术。切线是顶点的一个属性,它定义了与法线(normal)和副切线(bitangent)一起构成的正交基(TBN矩阵),用于将法线贴图中的法线从切线空间转换到世界空间。
切线的计算过程较为复杂,而且只能用于使用EBO的几何体数据,而且要保证position、normal和uv以及index属性都存在。
切线的一半计算流程为:
-
计算三角形的边向量:
\(edge1 = v1 - v0\)
\(edge2 = v2 - v0\)
-
计算UV的边向量:
\(deltaUV1 = uv1 - uv0\)
\(deltaUV2 = uv2 - uv0\)
-
根据以下公式计算切线和副切线:
tangent = (deltaUV2.y * edge1 - deltaUV1.y * edge2) / (deltaUV1.x * deltaUV2.y - deltaUV1.y * deltaUV2.x) bitangent = (deltaUV1.x * edge2 - deltaUV2.x * edge1) / (deltaUV1.x * deltaUV2.y - deltaUV1.y * deltaUV2.x)注意:分母相同,可以提前计算。
但更常见的做法是使用矩阵形式求解:
设:
[ edge1.x, edge2.x ] [ T.x, B.x ] [ deltaUV1.x, deltaUV2.x ]
[ edge1.y, edge2.y ] = [ T.y, B.y ] * [ deltaUV1.y, deltaUV2.y ]
[ edge1.z, edge2.z ] [ T.z, B.z ]
即
则
- 将计算出来的切线和副切线累加到三角形的每个顶点上
- 最后,对每个顶点的切线和副切线进行正交化,此步通常使用Gram-Schmidt正交化,然后归一化。此外,为了保持切线方向的一致性,还可以存储一个用于副切线方向的手性handedness在切线的w分量中,如果副切线与法向和切线的叉积相同,则w=1,否则w=-1
以下是threejs的实现过程
第一步,创建两个Vector3数组,用于存储切线累积量和副切线累积量。
const tan1 = [], tan2 = [];
for ( let i = 0; i < positionAttribute.count; i ++ ) {
tan1[ i ] = new Vector3();
tan2[ i ] = new Vector3();
}
第二步,定义处理三角形的函数,用于累加计算切线和副切线
function handleTriangle( a, b, c ) {
// 获取顶点位置和UV坐标
vA.fromBufferAttribute( positionAttribute, a );
vB.fromBufferAttribute( positionAttribute, b );
vC.fromBufferAttribute( positionAttribute, c );
uvA.fromBufferAttribute( uvAttribute, a );
uvB.fromBufferAttribute( uvAttribute, b );
uvC.fromBufferAttribute( uvAttribute, c );
// 计算边向量
vB.sub( vA );
vC.sub( vA );
// 计算UV差
uvB.sub( uvA );
uvC.sub( uvA );
// 以下是矩阵求逆过程
// 行列式的倒数
const r = 1.0 / ( uvB.x * uvC.y - uvC.x * uvB.y );
// silently ignore degenerate uv triangles having coincident or colinear vertices
if ( ! isFinite( r ) ) return;
// 计算切线和副切线(即第三步的公式)
sdir.copy( vB ).multiplyScalar( uvC.y ).addScaledVector( vC, - uvB.y ).multiplyScalar( r );
tdir.copy( vC ).multiplyScalar( uvB.x ).addScaledVector( vB, - uvC.x ).multiplyScalar( r );
// 累加到三个顶点
tan1[ a ].add( sdir );
tan1[ b ].add( sdir );
tan1[ c ].add( sdir );
tan2[ a ].add( tdir );
tan2[ b ].add( tdir );
tan2[ c ].add( tdir );
}
第四步,遍历几何体的三角形,进行切线副切线的计算
for ( let i = 0, il = groups.length; i < il; ++ i ) {
const group = groups[ i ];
const start = group.start;
const count = group.count;
for ( let j = start, jl = start + count; j < jl; j += 3 ) {
handleTriangle(
index.getX( j + 0 ),
index.getX( j + 1 ),
index.getX( j + 2 )
);
}
}
第五步,顶点后处理函数
function handleVertex( v ) {
// 获取顶点的法线
n.fromBufferAttribute( normalAttribute, v );
n2.copy( n );
// 获取累计的切线
const t = tan1[ v ];
// Gram-Schmidt正交化
// Gram-Schmidt orthogonalize
tmp.copy( t );
tmp.sub( n.multiplyScalar( n.dot( t ) ) ).normalize();
// 计算手性
// Calculate handedness
tmp2.crossVectors( n2, t );
const test = tmp2.dot( tan2[ v ] );
const w = ( test < 0.0 ) ? - 1.0 : 1.0;
// 设置法线和手性
tangentAttribute.setXYZW( v, tmp.x, tmp.y, tmp.z, w );
}
第六步,处理每个顶点
for ( let i = 0, il = groups.length; i < il; ++ i ) {
const group = groups[ i ];
const start = group.start;
const count = group.count;
for ( let j = start, jl = start + count; j < jl; j += 3 ) {
handleVertex( index.getX( j + 0 ) );
handleVertex( index.getX( j + 1 ) );
handleVertex( index.getX( j + 2 ) );
}
}
将索引数据转为非索引数据
即将带有index的几何体转为不带index的几何体。
内部的原理也十分简单,当几何体带index时,我们只需要遍历每个三角形,然后将三角形三个顶点对应的位置拷贝一份,放到新的position数组中,这样,新的顶点位置数组的顺序就是原先index的顺序,只需要按正常的drawArray渲染即可。
BufferAttribute
前文提到了,BufferGeometry的index属性和attributes属性中,使用的都是BufferAttribute类型的数据。
BufferAttribute是包装的缓冲对象,其主要内容有三个:
array:一个TypedArray数组,记录着该顶点属性的所有值itemSize:定义该属性中,每个顶点需要用到几个数值normalized:标识数据是否是归一化的
其他的数据还有
count:标识该属性一共对多少个顶点数据进行赋值,计算方式为array.length / itemSize,由于array是所有的数据,itemSize是每个顶点所需要的数据,那么需要赋值的顶点数量即为两者的商usage:用途,相当于gl.bindBuffer函数的最后一个参数updateRange:更新范围,其值为一个对象数组,对象中包含start属性,指定需要更新的buffer的起始索引,以及count属性,标识要更新的buffer的数量gpuType,在绘制时,使用的GPU数据类型,类似于在gl.vertexAttribPointer中指定的数据的类型
BufferAttribute操作主要就是对其内部的array进行操作,可以进行矩阵变换,以及各种类型的get/set
由于BufferAttribute中的array要求是个结构化数组,因此,threejs对不同类型的结构化数组的BufferAttribute进行了封装.
- Float32BufferAttribute
- Float16BufferAttribute
- Uint32BufferAttribute
- Int32BufferAttribute
- Uint16BufferAttribute
- Int16BufferAttribute
- Uint8ClampedBufferAttribute
- Uint8BufferAttribute
- Int8BufferAttribute,
使用这些attribute时,只需要直接传入普通数组,然后内部会自动转为对应的结构化数组
其中Float16BufferAttribute稍微有些特殊,由于js中float类型都是32位的,因此,对于16位的浮点数会做一些额外处理。如何处理,不在此处详细介绍。
InstancedBufferGeometry
在webgl2中,有一种渲染方式称为“实例渲染”,在webgl1中也可以使用扩展来实现这个功能,这个功能的作用是,可以一次性渲染多个具有相同几何体和材质的实体,而对于这些实体,可以指定每一个实体的变换矩阵,达到具有同一个几何体的对象渲染在不同的地方、缩放、旋转的功能。这一点的好处就是,只需要一份几何体的顶点attribute数据,然后多个实体共享这份顶点数据,达到渲染多个、且不同姿态的对象的目的,在大量相同形状的物体的渲染中,可以节省不少内存。
而threejs中InstancedBufferGeometry就是用于定义此类渲染物体的几何体形状的。该类继承自BufferGeometry,但是多了一个属性,instanceCount,表示这个几何体被多少个实例对象共享。指定这个值的目的是后续在定义变换矩阵时,提前知道需要存储多少个变换矩阵。
InstancedBufferGeometry只可用于实例网格对象InstancedMesh,在网格体对象中,会对各个实例对象的变换矩阵进行定义,这部分以后在讲。
InstancedBufferAttribute
对于InstancedBufferGeometry,其顶点属性对象的类型为InstancedBufferAttribute,继承自BufferAttribute。内部存储的也是顶点数据的结构化数组,但是多了一个属性,meshPerAttribute,表示这个属性的每一个值被多少个实例使用,默认为1,即此buffer中每一个属性只给一个实例使用。InstancedMesh的实例的变换矩阵实际上也是一个InstancedBufferAttribute,是个itemSize=16的属性,即,每个实例会赋予一个4×4的矩阵用于变换。
例如,我想使用实例方式绘制N个点,然后对这N个点分别赋予不同的颜色,则我们设置的颜色数据的buffer,数据个数应该是N*3(每个颜色有3个分量)。但是,如果我们想从第一个点开始,每两个点绘制同一种颜色,则可以设置meshPerAttribute属性为2,然后buffer的数据个数变为N / 2 * 3,其内部会自动重复2遍。
在官方的webgl_buffergeometry_instancing示例中,使用实例渲染绘制了50000个三角形。并为每个三角形设置了偏移值、颜色、旋转属性。
我们只看偏移量部分:
const instances = 50000; // 实例个数
const positions = []; // 顶点位置
const offsets = []; // 实例偏移量
positions.push( 0.025, - 0.025, 0 ); // 顶底位置属性,只需要设置一份
positions.push( - 0.025, 0.025, 0 );
positions.push( 0, 0, 0.025 );
// 设为每个实例设置偏移量
for ( let i = 0; i < instances; i ++ ) {
// offsets
offsets.push( Math.random() - 0.5, Math.random() - 0.5, Math.random() - 0.5 );
}
// 创建几何体
const geometry = new THREE.InstancedBufferGeometry();
geometry.instanceCount = instances; // set so its initialized for dat.GUI, will be set in first draw otherwise
geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( positions, 3 ) ); // position会被共用
geometry.setAttribute( 'offset', new THREE.InstancedBufferAttribute( new Float32Array( offsets ), 3 ) ); // 实例属性,每个实例的偏移量
InterleavedBufer和InstancedInterleavedBuffer
在我们使用buffer的时候,我们有时候会将一个顶点有关的所有的属性数据都写进去,比如说,我们需要写入顶点的位置数据(3个),颜色数据(3个)和UV数据(2个),那我们在存储数据时,可以以8个Float32为一组,作为一个顶点的所有属性,前三个32位表示位置,下面三个32位表示颜色,在再后面是UV的两个32位,然后是下一个顶点的数据,如此循环。
InterleavedBuffer就是实现这个功能的。它也是普通Buffer的包装对象,内部存储的两个量,一个是array,表示整个buffer,一个是stride,表示每个顶点需要用到多少个属性。
InstancedInterleavedBuffer是InterleavedBuffer的实例形式,可以指定一个属性用于多个顶点。
InterleavedBufferAttribute
要使用InterleavedBuffer,就要使用InterleavedBufferAttribute定义顶点的属性。内部会传入四个值:
InterleavedBuffer:buffer数据itemSize:表示每个顶点用到多少个buffer中的数据offset:该属性的偏移值normalized:是否归一化
以上面位置数据、颜色数据、UV数据为例,我们可以创建一个InterleavedBuffer
const buffer = new Float32Array([
// 顶点位置 颜色属性 UV
0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.5, 0.5,
...
]);
const interleavedBuffer = new InterleavedBuffer(buffer, 8); // 每个顶点用8个数值
之后,我们创建InterleavedBufferAttribute
const positionAttribute = new InterleavedBufferAttribute(
interleavedBuffer, // interleavedBuffer
8, // 每个顶点8个数值
0, // position数据在8个数值中的偏移量为0
false
);
const colorAttribute = new InterleavedBufferAttribute(
interleavedBuffer, // interleavedBuffer
8, // 每个顶点8个数值
3, // color数据在8个数值中的偏移量为3
false
);
const uvAttribute = new InterleavedBufferAttribute(
interleavedBuffer, // interleavedBuffer
8, // 每个顶点8个数值
6, // uv数据在8个数值中的偏移量为6
false
);
创建geometry并设置属性
const geometry = new BufferGeometry();
geometry.setAttribute("position", positionAttribute);
geometry.setAttribute("color", colorAttribute);
geometry.setAttribute("uv", uvAttribute);

浙公网安备 33010602011771号