Three.js 自定义缓冲几何体(Custom BufferGeometry):从零拼出你自己的模型
原文出处:Three.js Manual – Custom BufferGeometry
本文基于官方手册 Custom BufferGeometry 章节整理,带你理解 three.js 里所有模型的底层——BufferGeometry到底是怎么存数据的。
写在前面
前面我们用过BoxGeometry、SphereGeometry这些"图元",直接拿来就能画。但你有没有想过:这些形状底层到底是什么?
答案就是BufferGeometry。可以这么说——three.js 里所有几何体,本质上都是BufferGeometry。
BufferGeometry说白了就是一组带名字的BufferAttribute(缓冲属性)的集合。每个BufferAttribute装一种数据:position(位置)、normal(法线)、color(颜色)、uv(贴图坐标)……它们是一组平行数组——第 N 个属性里的第 N 份数据,属于同一个顶点。
理解这一点,你就能自己造任何形状了。
一、一个顶点 = 它所有零件的合集
先记住一句关键的话:一个顶点,是它全部数据的组合。只要某一块数据不一样,它就必须是另一个顶点。
举个最经典的例子——立方体的一个角。视觉上 3 个面共用同一个角点,但实际上不行:
- 这个角在 3 个面上,每个面朝向不同,所以法线(normal)必须不一样;
- 每个面贴的纹理位置不同,所以UV 坐标也必须不一样。
既然法线和 UV 都不同,那这三个"角"就是三个不同的顶点,不能共享。
这也是为什么:一个看似 8 个角的立方体,真正需要36 个顶点——每个面 2 个三角形 × 3 个顶点 × 6 个面 = 36。
二、第一步:把顶点数据列出来
我们手动把立方体的 36 个顶点全写出来(每个含pos、norm、uv):
constvertices=[// front{pos:[-1,-1,1],norm:[0,0,1],uv:[0,0],},{pos:[1,-1,1],norm:[0,0,1],uv:[1,0],},{pos:[-1,1,1],norm:[0,0,1],uv:[0,1],},{pos:[-1,1,1],norm:[0,0,1],uv:[0,1],},{pos:[1,-1,1],norm:[0,0,1],uv:[1,0],},{pos:[1,1,1],norm:[0,0,1],uv:[1,1],},// right(省略其余面,结构一样)...];三、第二步:拆成平行数组
把上面这份对象数组,拆成 three.js 要的纯数字平行数组:
constpositions=[];constnormals=[];constuvs=[];for(constvertexofvertices){positions.push(...vertex.pos);normals.push(...vertex.norm);uvs.push(...vertex.uv);}四、第三步:塞进 BufferGeometry
创建几何体,把每个数组包成BufferAttribute加进去:
constgeometry=newTHREE.BufferGeometry();constpositionNumComponents=3;// 每个顶点 3 个分量:x, y, zconstnormalNumComponents=3;constuvNumComponents=2;// uv 只有 2 个:u, vgeometry.setAttribute('position',newTHREE.BufferAttribute(newFloat32Array(positions),positionNumComponents));geometry.setAttribute('normal',newTHREE.BufferAttribute(newFloat32Array(normals),normalNumComponents));geometry.setAttribute('uv',newTHREE.BufferAttribute(newFloat32Array(uvs),uvNumComponents));⚠️两个坑:
- 名字不能乱起。
position、normal、uv是 three.js 约定好的名字(想要顶点色就用color)。除非你自己写着色器,否则名字必须对应。BufferAttribute只认 TypedArray,不认原生数组。所以这里用new Float32Array(...)包了一层。它还要求你告诉它"每个顶点几个分量"——位置/法线是 3,UV 是 2。
五、进阶:用索引省顶点
上面 36 个顶点里有不少是重复的——每个面的 2 个三角形共 6 个顶点,其中有 2 个完全相同(位置、法线、UV 一模一样)。把这些重复的提出来,立方体其实只需24 个不重复顶点。
做法是:保留 24 个顶点,再用一个索引数组告诉 GPU “三角形该按什么顺序画这 24 个顶点”:
geometry.setIndex([0,1,2,2,1,3,// front4,5,6,6,5,7,// right8,9,10,10,9,11,// back12,13,14,14,13,15,// left16,17,18,18,17,19,// top20,21,22,22,21,23,// bottom]);索引就是"顶点的身份证号",GPU 按这个顺序拼三角形。24 个顶点 + 36 个索引,照样画出 12 个三角形,但数据量小了不少。
六、一个隐藏的坑:computeVertexNormals 会留缝
如果你懒得写法线,可以用geometry.computeVertexNormals()让 three.js 自动算。但前面说了:只要顶点有任何一块不一样就不能共享。球体、圆柱这种"首尾相接"的形状,接缝处的两个顶点因为 UV 不同而无法共享,自动算法就不知道它们该平滑过渡——于是圆柱上会多出一条明显的接缝(seam)。
解决办法?自己提供法线,别偷这个懒。
七、直接用 TypedArray 起步 + 动态更新
原生数组方便push,但如果你想频繁改顶点,最好一开始就上Float32Array(TypedArray 没有push,得自己记账):
constnumVertices=vertices.length;constpositions=newFloat32Array(numVertices*3);letposNdx=0;for(constvertexofvertices){positions.set(vertex.pos,posNdx);posNdx+=3;}动态更新的精髓是这两行:
constpositionAttribute=newTHREE.BufferAttribute(positions,3);positionAttribute.setUsage(THREE.DynamicDrawUsage);// 告诉 three.js:这属性我会常改// ...渲染循环里改完 positions 后:positionAttribute.needsUpdate=true;// 必须标记!否则改了也白改官方还用这个思路做了个例子:生成一个球,每帧让每个小方格沿法线方向进进出出地"呼吸",也就是靠needsUpdate = true把新位置实时喂给 GPU。
总结
| 概念 | 一句话说明 |
|---|---|
| BufferGeometry | 一组带名字的 BufferAttribute(平行数组) |
| 一个顶点 | 位置+法线+UV+颜色 的全组合,任一处不同 = 不同顶点 |
| 立方体顶点数 | 36(不共享)或 24 + 索引(共享) |
| setAttribute | 名字要对(position/normal/uv),值要 TypedArray |
| setIndex | 用索引复用顶点,省数据 |
| 动态更新 | setUsage(DynamicDrawUsage)+needsUpdate = true |
什么时候自己写 BufferGeometry?当现成图元满足不了你(自定义地形、动态网格、粒子、程序化生成的形状)时,它就是你的底层武器。
延伸阅读
- BufferGeometry API
- BufferAttribute API
- Primitives(现成图元)