Skip to content

自定义几何体

什么是 BufferGeometry

Threejs 中提供了多种几何体,但是有些时候我们可能需要自定义几何体,比如绘制一个心形,或者一个不规则的形状。这时就需要使用更加底层的BufferGeometry

BufferGeometry 是 Three.js 中用于存储几何体数据的高效数据结构,本质上是一系列 BufferAttributes的名称。BufferAttributes主要有以下几种类型:

  • position(位置):顶点的 3D 坐标 (x, y, z)
  • normal(法线):用于光照计算的法线向量
  • uv(UV 坐标):用于纹理映射的 2D 坐标 (u, v)
  • index(索引):定义面的顶点索引
  • color(颜色):顶点颜色(可选)

以上属性是并行存储在各自的数组中的,代表每个属性的第N个数据集属于同一个顶点。如下图就是其中一个顶点的所有信息:

img

这里没有提到index,后面会再做说明。

立方体的顶点组成

img

在这张图中,不同的面都需要一个不同的法线。法线是面朝向的信息。因此每一个顶点都需要有各自不同方向上的法线,如图中坐标轴所示。

同理,一个角在不同的面需要不同的UVs。UVs是用来指定纹理区域中,画在相应顶点位置三角形的纹理坐标。你可以看到,绿色的面需要顶点的UV对应于F纹理的右上角,蓝色的面需要的UV对应于F纹理的左上角,红色的面需要的UV对应于F纹理的左下角。

从这里可以发现:一个 顶点 (Vertex) 在图形学里,并不是只有一个 3D 空间坐标 (x,y,z),它其实是一个 属性集合

  • 位置 position
  • 法线 normal
  • 纹理坐标 uv
  • 颜色 color
  • …等等

只要其中任意一个属性不同,这个点就必须被当作“不同的顶点”

以立方体为例,一个立方体看似只有 8 个空间顶点,但实际上:

  • 每个角点连接了 3 个面
  • 不同的面有不同的 法线(面朝向不同)
  • 不同的面需要不同的 UV 坐标(从纹理图中取不同的部分)

所以,一个“顶点”在实际几何数据里必须复制 3 次,分别带上不同的法线和 UV,对于立方体,实际上需要 36个顶点(每个面2个三角形,每个三角形3个顶点,6个面=36个顶点)。

WebGL绘制图形的方式

WebGL中只能绘制三种图形:点、线段和三角形。但是,我们所看到的任何图形,都可以由小的三角形组成。实际上,可以使用这些最基本的图形来绘制出任何东西。

image-20250817182939752

这里我们主要讨论绘制三角形的方式,其他的暂不讨论。由图上可知,绘制三角形有三种方式:

基本图形绘制方式描述
三角形TRIANGLES一系列单独的三角形,绘制在(v0, v1, v2)、(v3, v4, v5)...处,如果点的个数不是整数倍,最后剩下的一或两个点将被忽略。
三角带TRIANGLE_STRIP一系列条带状的三角形,前3个点构成了第1个三角形,从第2个点开始的三个点构成了第2个三角形(该三角形与前一个三角形共享一条边),以此类推。这些三角形被绘制在(v0, v1, v2)、(v2, v1, v3)、(v2, v3, v4)...处(注意点的顺序)。
三角扇TRIANGLE_FAN一系列三角形组成的类似于扇形的图形。前三个点构成了第1个三角形,接下来的一个点和前一个三角形的最后一条边组成接下来的一个三角形,这些三角形被绘制在(v0, v1, v2),(v0, v2, v3),(v0, v3, v4)... 处。

实际 3D 开发里,几乎所有引擎(Three.js, Babylon.js, Unity, Unreal)内部都用 TRIANGLES,因为它是 GPU 原生支持的通用格式。后面说到的绘制方式也是用TRIANGLES实现,所以:

总结

绘制一个面需要两个三角形,绘制一个三角形需要三个点。

实现绘制立方体

根据上述的理论基础,现在可以写一个立方体的绘制过程。

position

每个面2个三角形 × 6面 = 12三角形 × 3顶点 = 36个顶点。

image-20250817212811547

坐标方向依次为:红色代表 X 轴. 绿色代表 Y 轴. 蓝色代表 Z 轴.

javascript

const positions = new Float32Array([
  // 前面 (z=1)
  -1,-1, 1,   1,-1, 1,   1, 1, 1,
  -1,-1, 1,   1, 1, 1,  -1, 1, 1,

  // 后面 (z=-1)
  -1,-1,-1,  -1, 1,-1,   1, 1,-1,
  -1,-1,-1,   1, 1,-1,   1,-1,-1,

  // 上面 (y=1)
  -1, 1,-1,  -1, 1, 1,   1, 1, 1,
  -1, 1,-1,   1, 1, 1,   1, 1,-1,

  // 下面 (y=-1)
  -1,-1,-1,   1,-1,-1,   1,-1, 1,
  -1,-1,-1,   1,-1, 1,  -1,-1, 1,

  // 右面 (x=1)
   1,-1,-1,   1, 1,-1,   1, 1, 1,
   1,-1,-1,   1, 1, 1,   1,-1, 1,

  // 左面 (x=-1)
  -1,-1,-1,  -1,-1, 1,  -1, 1, 1,
  -1,-1,-1,  -1, 1, 1,  -1, 1,-1,
]);

normals

每个面法线固定(6个方向)。

image-20250817213707750

每个黄色线条代表了每个点在每个面上的法线方向。

javascript
const normals = new Float32Array([
  // 前 (0,0,1)
  0,0,1, 0,0,1, 0,0,1,
  0,0,1, 0,0,1, 0,0,1,
  // 后 (0,0,-1)
  0,0,-1, 0,0,-1, 0,0,-1,
  0,0,-1, 0,0,-1, 0,0,-1,
  // 上 (0,1,0)
  0,1,0, 0,1,0, 0,1,0,
  0,1,0, 0,1,0, 0,1,0,
  // 下 (0,-1,0)
  0,-1,0, 0,-1,0, 0,-1,0,
  0,-1,0, 0,-1,0, 0,-1,0,
  // 右 (1,0,0)
  1,0,0, 1,0,0, 1,0,0,
  1,0,0, 1,0,0, 1,0,0,
  // 左 (-1,0,0)
  -1,0,0, -1,0,0, -1,0,0,
  -1,0,0, -1,0,0, -1,0,0,
]);

uv

每个三角形的UV。这里先介绍以下UV坐标系。

UV坐标系

U和V分别表示纹理坐标的水平和垂直方向,使用UV来表达纹理的坐标系。 UV坐标的取值范围是0~1,纹理贴图左下角对应的UV坐标是(0,0),右上角对应的坐标(1,1)。

image-20250817221434083

javascript
const uv = new Float32Array([
  // 前
  0,0, 1,0, 1,1,
  0,0, 1,1, 0,1,
  // 后
  0,0, 1,1, 1,0,
  0,0, 0,1, 1,1,
  // 上
  0,0, 1,0, 1,1,
  0,0, 1,1, 0,1,
  // 下
  0,0, 1,0, 1,1,
  0,0, 1,1, 0,1,
  // 右
  0,0, 1,0, 1,1,
  0,0, 1,1, 0,1,
  // 左
  0,0, 1,0, 1,1,
  0,0, 1,1, 0,1,
]);

有了上面的三种坐标,就可以对其组装为一个几何体:

javascript
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));

const material = new THREE.MeshStandardMaterial({ 
    color: 0x44aa88, 
    metalness: 0.1, 
    roughness: 0.7 
});
const cube = new THREE.Mesh(geometry, material);

// 添加到场景中
scene.add(cube);

这样,就在场景中创建了一个几何体:

image-20250817221939485

通过顶点索引优化性能

看回我们的position数据,每个面由2个三角形组成,每个三角形3个顶点,总共6个,但是其中2个是完全一样的,这在顶点数多的几何体中会出现大量的重复点坐标。我们可以移除重复的顶点,然后用索引代表他们。

position

这次position是去除重复点后的数组,每个面4个顶点(位置),总共6个面 → 24个顶点。

javascript
const positions = new Float32Array([
  // 前面
  -1, -1,  1,
   1, -1,  1,
   1,  1,  1,
  -1,  1,  1,
  // 后面
  -1, -1, -1,
  -1,  1, -1,
   1,  1, -1,
   1, -1, -1,
  // 上面
  -1,  1, -1,
  -1,  1,  1,
   1,  1,  1,
   1,  1, -1,
  // 下面
  -1, -1, -1,
   1, -1, -1,
   1, -1,  1,
  -1, -1,  1,
  // 右面
   1, -1, -1,
   1,  1, -1,
   1,  1,  1,
   1, -1,  1,
  // 左面
  -1, -1, -1,
  -1, -1,  1,
  -1,  1,  1,
  -1,  1, -1,
]);

index

用点位的索引告诉geometry可以用哪些点进行绘制

javascript
// 顶点索引(每个面两个三角形)
const indices = [
   0,  1,  2,   0,  2,  3,  // 前
   4,  5,  6,   4,  6,  7,  // 后
   8,  9, 10,   8, 10, 11,  // 上
  12, 13, 14,  12, 14, 15,  // 下
  16, 17, 18,  16, 18, 19,  // 右
  20, 21, 22,  20, 22, 23,  // 左
];

normaluv保持不变,组装geometry的代码如下,需要调用geometry.setIndex(indices)

javascript
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
geometry.setAttribute("normal", new THREE.BufferAttribute(normals, 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(uvs, 2));
geometry.setIndex(indices);

至此,我们得到了优化过顶点数的立方体。