three.js自定义几何体

1,418 阅读6分钟

1-1-基本概念

BufferGeometry 是所有three.js 内置几何体的基类,通过此基类可以自定义几何体。

BufferGeometry 具备以下重要属性:

  • position 顶点位置
  • index 顶点索引
  • normal 法线
  • uv 坐标
  • color 顶点颜色

image.png

从上图可以看出,在顶点索引为4的地方,position 、normal 、uv 、color 是基于顶点一一对应的。

接下来咱们自己自定义一个几何体。

1-2-自定义立方体

1.在下面的vertices里,我们按照对立三角形的绘图方式,定义了立方体的6个面,共36个顶点。

const vertices = [
  // 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
  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], },
  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
 
  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], },
  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], },
  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], },
  // back
  { 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], },
  // left
  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], },
  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
 
  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], },
  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], },
  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], },
  // top
  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], },
  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
 
  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], },
  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], },
  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], },
  // bottom
  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], },
  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
 
  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], },
  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], },
  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], },
];

2.按照属性将这些顶点分成三组:

const positions = [];
const normals = [];
const uvs = [];
for (const vertex of vertices) {
  positions.push(...vertex.pos);
  normals.push(...vertex.norm);
  uvs.push(...vertex.uv);
}

3.基于positions、normals 和uvs 建立BufferAttribute 对象。

const geometry = new BufferGeometry();
const positionNumComponents = 3;
const normalNumComponents = 3;
const uvNumComponents = 2;
const positionAttr=new BufferAttribute(
  new Float32Array(positions), 
  positionNumComponents
)
const normalAttr=new BufferAttribute(
  new Float32Array(normals), 
  normalNumComponents
)
const uvAttr=new BufferAttribute(
  new Float32Array(uvs), 
  uvNumComponents
)

BufferAttribute 对象就是对顶点着色器中Attribute 变量的管理,通过此对象可以存储顶点点位、顶点数量、矢量长度等,并可以对其进行矩阵变换、拷贝、读写等。

4.将BufferAttribute 对象添加到geometry 几何体中。

geometry.setAttribute('position',positionAttr);
geometry.setAttribute('normal',normalAttr);
geometry.setAttribute('uv',uvAttr);

在setAttribute()中,'position'、'normal'、'uv'、' color ' 都是内置attribute 变量名,不能随便写。

当然如果我们想自定义attribute 变量名,那就可以随便写了,只要符合基本的命名规范就行。

几何体形状如下:

image-20220505114047411

自定义几何体的基本方法就是这样,接下来我们还可以使用顶点索引自定义几何体。

1-3-顶点索引

1.将vertices 设置为4*6=24 个点,一个面4个点。

const vertices = [
  // front
  { pos: [-1, -1,  1], norm: [ 0,  0,  1], uv: [0, 0], }, // 0
  { pos: [ 1, -1,  1], norm: [ 0,  0,  1], uv: [1, 0], }, // 1
  { pos: [-1,  1,  1], norm: [ 0,  0,  1], uv: [0, 1], }, // 2
  { pos: [ 1,  1,  1], norm: [ 0,  0,  1], uv: [1, 1], }, // 3
  // right
  { pos: [ 1, -1,  1], norm: [ 1,  0,  0], uv: [0, 0], }, // 4
  { pos: [ 1, -1, -1], norm: [ 1,  0,  0], uv: [1, 0], }, // 5
  { pos: [ 1,  1,  1], norm: [ 1,  0,  0], uv: [0, 1], }, // 6
  { pos: [ 1,  1, -1], norm: [ 1,  0,  0], uv: [1, 1], }, // 7
  // back
  { pos: [ 1, -1, -1], norm: [ 0,  0, -1], uv: [0, 0], }, // 8
  { pos: [-1, -1, -1], norm: [ 0,  0, -1], uv: [1, 0], }, // 9
  { pos: [ 1,  1, -1], norm: [ 0,  0, -1], uv: [0, 1], }, // 10
  { pos: [-1,  1, -1], norm: [ 0,  0, -1], uv: [1, 1], }, // 11
  // left
  { pos: [-1, -1, -1], norm: [-1,  0,  0], uv: [0, 0], }, // 12
  { pos: [-1, -1,  1], norm: [-1,  0,  0], uv: [1, 0], }, // 13
  { pos: [-1,  1, -1], norm: [-1,  0,  0], uv: [0, 1], }, // 14
  { pos: [-1,  1,  1], norm: [-1,  0,  0], uv: [1, 1], }, // 15
  // top
  { pos: [ 1,  1, -1], norm: [ 0,  1,  0], uv: [0, 0], }, // 16
  { pos: [-1,  1, -1], norm: [ 0,  1,  0], uv: [1, 0], }, // 17
  { pos: [ 1,  1,  1], norm: [ 0,  1,  0], uv: [0, 1], }, // 18
  { pos: [-1,  1,  1], norm: [ 0,  1,  0], uv: [1, 1], }, // 19
  // bottom
  { pos: [ 1, -1,  1], norm: [ 0, -1,  0], uv: [0, 0], }, // 20
  { pos: [-1, -1,  1], norm: [ 0, -1,  0], uv: [1, 0], }, // 21
  { pos: [ 1, -1, -1], norm: [ 0, -1,  0], uv: [0, 1], }, // 22
  { pos: [-1, -1, -1], norm: [ 0, -1,  0], uv: [1, 1], }, // 23
];

2.用BufferGeometry.setIndex() 方法设置顶点索引。

geometry.setIndex([
   0,  1,  2,   2,  1,  3,  // front
   4,  5,  6,   6,  5,  7,  // right
   8,  9, 10,  10,  9, 11,  // back
  12, 13, 14,  14, 13, 15,  // left
  16, 17, 18,  18, 17, 19,  // top
  20, 21, 22,  22, 21, 23,  // bottom
]);

1-4-计算法线

BufferGeometry有一个自己根据现有顶点计算法线的方法computeVertexNormals()。

computeVertexNormals() 方法是按照逐顶点着色的方式计算法线的。

逐顶点着色的原理我们在webgl 的光里说过。

代码示例:

geometry.setAttribute("position", positionAttr);
// geometry.setAttribute("normal", normalAttr);
geometry.setAttribute("uv", uvAttr);
geometry.setIndex([0, 1, 2, 2, 1, 3, 4, 5, 6, 6, 5, 7, 8, 9, 10, 10, 9, 11, 12, 13, 14, 14, 13, 15, 16, 17, 18, 18, 17, 19, 20, 21, 22, 22, 21, 23]);
geometry.computeVertexNormals();

我们可以用VertexNormalsHelper 对象将一个Mesh对象的法线显示出来:

const mesh = new Mesh(geometry, material);
const helper = new VertexNormalsHelper(mesh);
scene.add(mesh, helper);

效果如下:

image-20220505154649428

注:若几何体有接缝,即在几何体接缝的地方有两排位置相同的顶点,则用computeVertexNormals()方法计算出的这两排顶点的法线可能会不同。

image-20220507094755514

1-5-顶点数据的更新

当我们想要修改几何体的顶点数据的时候,可以直接通过BufferGeometry 对象下的BufferAttribute 修改。

举个例子:我要在一秒后,把上面自定义的几何体打开一个口子。

image-20220507102640729

代码如下:

setTimeout(() => {
  positionAttr.setXYZ(18, 1, 2, 1);
  positionAttr.needsUpdate = true;
}, 1000);

接下来在渲染器执行render() 方法时,便会将最新的顶点数据传递给顶点着色器里的attribute 变量。

关于几何体的基础知识,咱们就说到这,接下来咱们做点好玩的东西,巩固咱们之前的所学。

案例-自定义波浪球

下面这个绚丽的波浪球就是我们接下来要做的,我之后还会让它蠕动来。其中会制涉及的知识点有自定义几何体的封装、几何体顶点数据的读写、正弦函数动画等。

image-20220507130846972

1.封装一个波浪球对象。

import {
  MeshBasicMaterial,
  MeshStandardMaterial,
  Mesh,
  PerspectiveCamera,
  Raycaster,
  Scene,
  Texture,
  TextureLoader,
  WebGLRenderer,
  Vector2,
  Color,
  BufferGeometry,
  Material,
  Vector3,
  Object3D,
  BufferAttribute,
  InterleavedBufferAttribute,
} from "three";
const pi2 = Math.PI * 2;

export default class WaveBall extends BufferGeometry {
  // 分段数
  widthSegments: number;
  heightSegments: number;
  // 正弦参数y=Asin(ωx+φ)
  a: number = 0.7;
  omega: number = 12;

  constructor(widthSegments: number = 18, heightSegments: number = 12) {
    super();
    this.widthSegments = widthSegments;
    this.heightSegments = heightSegments;
    this.init();
  }
  init() {
    const { widthSegments, heightSegments } = this;
    //网格线的数量
    const [width, height] = [widthSegments + 1, heightSegments + 1];
    //顶点数量
    const count = width * height;
    //顶点点位
    const positions = new Float32Array(count * 3);
    //顶点索引
    const indices = [];

    //根据经纬度计算顶点点位的方法
    const setPos = SetPos(positions);

    // 逐行列遍历
    for (let y = 0; y < height; ++y) {
      // 维度[-Math.PI/2,Math.PI/2]
      const lat = (y / heightSegments - 0.5) * Math.PI;
      for (let x = 0; x < width; ++x) {
        // 经度[0,Math.PI*2]
        const long = (x / widthSegments) * Math.PI * 2;
        // 设置顶点点位
        setPos(lat, long);
        // 设置顶点索引
        if (y && x) {
          // 一个矩形格子的左上lt、右上rt、左下lb、右下rb点
          const lt = (y - 1) * width + (x - 1);
          const rt = (y - 1) * width + x;
          const lb = y * width + (x - 1);
          const rb = y * width + x;
          indices.push(lb, rb, lt, lt, rb, rt);
        }
      }
    }
    this.setAttribute("position", new BufferAttribute(positions, 3));
    this.setIndex(indices);
    this.computeVertexNormals();
  }
  //波浪
  wave(phi = 0) {
    const { widthSegments, heightSegments, a, omega } = this;
    //网格线的数量
    const [width, height] = [widthSegments + 1, heightSegments + 1];
    // 顶点点位
    const posAttr = this.getAttribute("position");
    // 修改点位的方法
    const changePos = ChangePos(posAttr);
    // 一圈波浪线的总弧度
    const allAng = omega * pi2;
    // 每个分段的弧度
    const yAng = allAng / heightSegments;
    const xAng = allAng / widthSegments;
    // 逐行列遍历
    for (let y = 0; y < height; ++y) {
      // y向起伏
      const r0 = Math.sin(y * yAng + phi);
      // 基于y值做起伏衰减
      const decay = a * 0.2 + a * (0.5 - Math.abs(y / heightSegments - 0.5));
      for (let x = 0; x < width; ++x) {
        // x向起伏
        const r1 = Math.sin(x * xAng + phi);
        // 基于半径修改顶点位置
        changePos((r0 + r1) * decay + 1);
      }
    }
    // this.computeVertexNormals();
    // 顶点数据需要更新
    posAttr.needsUpdate = true;
  }
}

// 基于半径修改顶点位置
function ChangePos(attr: BufferAttribute | InterleavedBufferAttribute) {
  let index = 0;
  // 根据索引获取顶点
  const getXYZ = GetXYZ(attr);
  return function (r: number) {
    const p = getXYZ(index);
    p.setLength(r);
    // 设置指定索引位的顶点的x、y、z值
    attr.setXYZ(index, ...p.toArray());
    index += 1;
  };
}

// 根据索引获取顶点
function GetXYZ(attr: BufferAttribute | InterleavedBufferAttribute) {
  return function (ind: number) {
    return new Vector3(attr.getX(ind), attr.getY(ind), attr.getZ(ind));
  };
}

// 基于经纬度计算顶点点位
function SetPos(positions: Float32Array) {
  let posNdx = 0;
  //根据经纬度获取点位
  const getPoint = GetPoint();
  return function (lat: number, long: number) {
    const pos = getPoint(lat, long);
    positions.set(pos, posNdx);
    posNdx += 3;
    return pos;
  };
}
// 获取顶点位
function GetPoint() {
  //经度辅助对象
  const longHelper = new Object3D();
  //维度辅助对象
  const latHelper = new Object3D();
  // 顶点辅助对象
  const pointHelper = new Object3D();
  //构建层级关系
  longHelper.add(latHelper);
  latHelper.add(pointHelper);
  pointHelper.position.z = 1;
  //暂存顶点
  const temp = new Vector3();
  return function (lat: number, long: number) {
    // 旋转经、纬度辅助对象
    latHelper.rotation.x = lat;
    longHelper.rotation.y = long;
    // 更新longHelper的世界坐标位
    longHelper.updateMatrixWorld(true);
    // 返回longHelper的世界坐标位
    return pointHelper.getWorldPosition(temp).toArray();
  };
}

2.将波浪球实例化

const geometry = new WaveBall(48, 48);
geometry.wave();

3.之后我们也可以在连续渲染方法里将时间传递到geometry.wave()里,作为波浪的偏移值。

geometry.wave(time * 0.002);

参考链接:threejs.org/manual/#en/…