Three.js:几何体顶点局部变换的最优处理方案
嘿,你的问题我太懂了——处理合并后的BufferGeometry局部变换确实容易卡壳,我来给你捋几个实用方案,顺便解答你关于性能的疑惑~
1. 矩阵批量计算(CPU端优化版)
虽然还是要遍历目标顶点子集,但用矩阵来封装平移+旋转的组合变换,比手动计算每个坐标分量要简洁高效,而且代码可复用性强。
核心思路是先把你要做的变换(平移、旋转、缩放)组合成一个Matrix4,然后遍历目标顶点,用矩阵直接计算新坐标:
// 假设你的合并几何体是mergedGeo,要变换的顶点索引从startIdx到endIdx const transformMatrix = new THREE.Matrix4() .makeRotationY(Math.PI / 4) // 先绕Y轴旋转45度 .setPosition(1, 0, 0); // 再沿X轴平移1单位 const positions = mergedGeo.attributes.position.array; // 遍历目标顶点子集,应用矩阵变换 for (let i = startIdx; i < endIdx; i++) { const x = positions[i * 3]; const y = positions[i * 3 + 1]; const z = positions[i * 3 + 2]; // 直接用矩阵元素计算新坐标,避免创建Vector3对象(减少GC开销) const newX = transformMatrix.elements[0] * x + transformMatrix.elements[4] * y + transformMatrix.elements[8] * z + transformMatrix.elements[12]; const newY = transformMatrix.elements[1] * x + transformMatrix.elements[5] * y + transformMatrix.elements[9] * z + transformMatrix.elements[13]; const newZ = transformMatrix.elements[2] * x + transformMatrix.elements[6] * y + transformMatrix.elements[10] * z + transformMatrix.elements[14]; positions[i * 3] = newX; positions[i * 3 + 1] = newY; positions[i * 3 + 2] = newZ; } // 标记位置属性需要更新 mergedGeo.attributes.position.needsUpdate = true;
如果你的变换需要频繁调整,预先计算好矩阵后,每次只需要遍历顶点,代码维护起来比手动算三角函数方便太多。
2. 用Shader在GPU端做实时变换(性能最优)
如果你的局部变换是基于顶点分组(比如某些顶点属于同一个部件),完全可以把变换逻辑放到顶点着色器里,让GPU来处理,彻底绕开CPU遍历顶点的开销。
步骤大概是这样:
- 给合并后的几何体添加一个自定义属性(比如
groupID),标记每个顶点属于哪个组; - 在自定义顶点着色器里,根据
groupID对顶点应用不同的变换矩阵; - 要变换时,只需要更新着色器的uniform矩阵,不用碰顶点数据。
示例代码:
// 第一步:给几何体添加groupID属性,标记顶点分组 const groupIDs = new Uint16Array(mergedGeo.attributes.position.count); // 假设前2000个顶点是组0,剩下3000个是组1 for (let i = 0; i < 2000; i++) groupIDs[i] = 0; for (let i = 2000; i < 5000; i++) groupIDs[i] = 1; mergedGeo.setAttribute('groupID', new THREE.BufferAttribute(groupIDs, 1)); // 第二步:自定义顶点着色器 const customVertexShader = ` attribute float groupID; uniform mat4 groupMatrix0; // 组0的变换矩阵 uniform mat4 groupMatrix1; // 组1的变换矩阵 void main() { vec3 transformedPos = position; // 根据groupID应用对应矩阵 if (groupID == 0.0) { transformedPos = (groupMatrix0 * vec4(position, 1.0)).xyz; } else if (groupID == 1.0) { transformedPos = (groupMatrix1 * vec4(position, 1.0)).xyz; } gl_Position = projectionMatrix * modelViewMatrix * vec4(transformedPos, 1.0); } `; // 第三步:创建带自定义着色器的材质 const material = new THREE.MeshBasicMaterial({ vertexShader: customVertexShader, uniforms: { groupMatrix0: { value: new THREE.Matrix4() }, groupMatrix1: { value: new THREE.Matrix4() } } }); // 要变换时,直接更新uniform里的矩阵就行! material.uniforms.groupMatrix0.value.makeRotationY(Math.PI / 4).setPosition(1, 0, 0); // 不需要设置needsUpdate,因为GPU会实时计算变换
这种方案适合需要频繁变换的场景,性能碾压CPU端修改顶点的方式,尤其顶点数量多的时候。
3. 备选:如果还没合并,改用InstancedMesh
如果你的局部变换的顶点组原本是独立的几何体,其实不合并反而更高效——用InstancedMesh可以给每个实例单独设置矩阵变换,完全不用碰顶点,而且Draw Call也不会增加太多(因为InstancedMesh只用一次Draw Call)。不过这个方案需要你还没合并几何体,适合重构场景的时候考虑。
合并几何体的核心优势是减少Draw Call——如果你的场景原本有很多小几何体,合并成一个后,Draw Call从N变成1,这部分优化依然存在,哪怕你每帧修改顶点,GPU还是只需要一次Draw Call来渲染。
不过修改顶点确实会有额外开销:每次设置needsUpdate=true时,CPU端的顶点数据会重新上传到GPU。如果是频繁更新,建议把属性的usage设置为THREE.DynamicDrawUsage,让GPU知道这个缓冲区会经常变化,分配更合适的内存区域,减少上传开销。
总结:合并的Draw Call优化依然有效,但顶点更新的开销是额外的,能用上Shader或InstancedMesh的方案就尽量避免CPU端修改顶点。
内容的提问来源于stack exchange,提问作者RadiX

