three.js 自定义几何体

738 阅读6分钟

1-基本概念

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

BufferGeometry 具备以下重要属性:

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

image.png

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

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

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

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

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([
  // front
   0,  1,  2,   2,  1,  3, 
  // right
   4,  5,  6,   6,  5,  7,  
  // back
   8,  9, 10,  10,  9, 11, 
  // left
  12, 13, 14,  14, 13, 15,  
  // top
  16, 17, 18,  18, 17, 19, 
  // bottom
  20, 21, 22,  22, 21, 23,  
]);

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 cube = new Mesh(geometry, material);
const helper = new VertexNormalsHelper(cube);
scene.add(mesh, helper);

效果如下:

image-20220505154649428

在用computeVertexNormals() 方法计算法线的时候,若在光滑的物体表面发现了明显的接缝线,那这可能就是由两排位置相同、法线不同的顶点引起的。

这需要我们自己根据模型特征调整法线,将这两排顶点的法线调成一样。

image-20220527223829778

5-顶点数据的更新

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

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

image-20220507102640729

代码如下:

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

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

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

案例-自定义波浪球

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

image-20220507130846972

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

import {
    BufferGeometry,
    Vector3,
    Object3D,
    BufferAttribute,
    InterleavedBufferAttribute,
    Euler,
} 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) {
                // 经度[-Math.PI,Math.PI]
                const long = (x / widthSegments - 0.5) * 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)
                // changePos(r0 * r1 * decay + 1)
                // changePos(r1 * 0.1 + 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) {
        // 旋转经、纬度辅助对象
        longHelper.rotation.y = long
        latHelper.rotation.x = lat
        // 返回longHelper的世界坐标位
        return pointHelper.getWorldPosition(temp).toArray()
    }
}
// 欧拉
function getPoint(lat: number, long: number) {
    const euler = new Euler(lat, long, 0, "YXZ")
    const p = new Vector3(0, 0, 1)
    p.applyEuler(euler)
    return p.toArray()
}

2.将波浪球实例化

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

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

stage.beforeRender = (time = 0) => {
    geometry.wave(time * 0.002)
}

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