【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是对应的类型,如,三维盒子几何体的typeBoxGeometry
  • index:顶点索引。我们需要复用顶点来绘制三角形时,需要此项。对应的webgl内部实现是使用EBO绑定三角形索引数据,然后使用drawElements进行绘制
  • indirect:用于WebGPU的渲染中,用于存储使用计算着色器时所需的缓冲数据
  • attributes:用于记录内部所有的顶点属性数据。
  • morphAttributes:一个字典数据,用于记录几何体所有的变形目标的属性数据。所谓的变形目标,就是当你想实现一个能够动态变化的物体时,记录每一个关键帧的顶点属性数据,以便于在播放动画时,能够以平滑的当时进行变换。
  • morphTargetRelative:标识,变形目标中记录的是每个顶点的相对变换,还是使用变形目标中的数据替换原始顶点数据。
  • groups:组划分,threejs支持将同一个几何体划分为多个组,然后不同的组的数据使用不同的材质,从而达到一个mesh的各部分渲染不同的功能。这么做的好处是,一是可以减少webgl的buffer绑定的数量,不需要频繁切换buffer数据,提高性能,二是可以将一整个网格体视为一个整体,更好控制。
  • boundingBox:几何体顶点的包围盒
  • boundingSphere:几何体顶点的外界包围球
  • drawRange:绘制范围。threejs支持只渲染一个几何体的一部分,而非全部绘制。可以理解为drawArrays/drawElements中,指定绘制顶点数量的参数。
  • userData:用户自定义数据

BufferGeometryindirect/attributes的值,是一个Object对象,对象的键名是这个属性的名称,而值则是一个BufferAttribute对象。且,内部一些计算操作默认是对指定键名的几个属性的buffer数据进行计算的,包括

  • 顶点坐标buffer:position
  • 顶点法向buffer: normal
  • 顶点切线buffer: tangent
  • 顶点UV坐标buffer:uv

如果没有给这些buffer赋值,则不会进行对应的计算。

BufferGeometry中,除了其属性对象的set和get方法外,还有变换和几何计算的方法。如,applyMatrix4进行变换。BufferGeometry中的变换是针对几何体中的名为positionnormaltangent的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的几何体数据,而且要保证positionnormaluv以及index属性都存在。

切线的一半计算流程为:

  1. 计算三角形的边向量:

    \(edge1 = v1 - v0\)

    \(edge2 = v2 - v0\)

  2. 计算UV的边向量:

\(deltaUV1 = uv1 - uv0\)

\(deltaUV2 = uv2 - uv0\)

  1. 根据以下公式计算切线和副切线:

    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 ]

\[E=TB\times\Delta UV \]

\[TB=E\times(\Delta UV)^{-1} \]

  1. 将计算出来的切线和副切线累加到三角形的每个顶点上
  2. 最后,对每个顶点的切线和副切线进行正交化,此步通常使用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

前文提到了,BufferGeometryindex属性和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,表示每个顶点需要用到多少个属性。

InstancedInterleavedBufferInterleavedBuffer的实例形式,可以指定一个属性用于多个顶点。

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);
posted @ 2024-04-16 12:13  李煎饼_GISer  阅读(441)  评论(0)    收藏  举报