曲线转网格:在Three.js中再现Blender的建模艺术

407 阅读9分钟

前言

之前我们已经实现过了曲线修改器,曲线转网格的核心逻辑和它并没有区别。

之前的曲线修改器,即使使用了平行传输架构,仍有一些问题。主要原因是,我在three中定义的曲线是无限(特指采样范围超出了0-1)的,blender的做法是在超出之后按直线处理。

而且,之前的曲线修改器(几何版)本来也不适合用作曲线偏移动画,three/example中的着色器版本的曲线修改器是比较适合用作曲线动画的。

我这个曲线修改器就是建模用的。同样,今天这个曲线转网格也是建模用的,虽然说也能用作动画,但是,如果需要曲线动画,最好是在着色器里处理。

还有一件事,在实现这个曲线转网格的过程中,我就发现,自己似乎正在发明雨伞。因为,ExtrudeGeometry的核心逻辑正是曲线转网格,人家已经实现的很好了,只是曲线转网格更具有一般性。

因此,如果你知道什么是曲线转网格,而且也了解three的挤出几何体,那么完全可以直接在挤出几何体的基础上实现这个,可以直接看曲线半径曲线倾斜的实现。

一、概念溯源:Blender的曲线建模

Blender的"曲线转网格"(Curve to Mesh)本质是参数化建模范式的典型应用:

输入:两条曲线
路径曲线(Path Curve):定义几何体的中心走向
截面曲线(Profile Curve):定义横截面形状
输出:通过沿路径扫描截面的方式生成网格

  • 使用方法,对任意网格体使用修改器-几何节点,新建一个几何节点,就可以开始了。 image.png

按F3,即可搜索“曲线转网格”,当然也可以搜索其他节点。这样就用两个简单的曲线来示范一下。

image.png curve2mesh.gif image.png

二、Three.js实现

曲线转网格大概就分为以下几步。

  1. 定义路径曲线
  2. 定义轮廓曲线
  3. 采样路径曲线得到一堆点,以及点上的切线等信息
  4. 采样轮廓曲线,同样得到一组点数据,备用。
  5. 在每个路径曲线的点上,都放置一遍全部的轮廓曲线的点,这便是我们最终需要的顶点数据。
  6. 按照一定的顺序将这些点组装为三角面。

curve2meshPoint.gif

curve2meshLine.gif curve2meshFace.gif 严格来说,12两步是入参,不算在曲线转网格这个过程里面。接下来,就开始后面几步。

曲线采样

这个步骤想必各位已经十分熟悉了,就是取点。不过,不知道大家有没有注意到three的采样方法有两个,其中getPointAt这个方法非常复杂,大概看了一下逻辑之后,你可能会觉得多此一举,算了半天,输入一个系数,输出还是一个系数。其实是一个重采样的算法。

为什么要重采样?对于我们定义的任意曲线,f(t)=(x,y,z),从系数t到点(x,y,z)的这样一个映射,我们假设取11个点,间隔0.1取一次。 然后,这11个点,它们的间距是否相等呢? 如果函数是一条直线,或者圆,那么是相等的。如果函数是正弦类曲线或者不规则折线,那么大概率是不等的。

重采样这一过程,就是保证,采样后的点,距离是一样的。采样之前的曲线是无限连续的,采样后的点会组成的新的折线,如果不做重采样,那么在实现一个曲线运动的时候,实际移动速率很难保证匀速。

而且,如果采样后点不是均匀的,结果网格肯定也不匀称。

那么,我们曲线转网格这个函数先确定一下入参,没错是函数,而不是一个class。因为我只是想实现曲线转网格这一过程,它的输出类型就是BufferGeometry,这个过程中我也不会去修改曲线,所以这正是一个纯函数。

入参,两条曲线,以及它们的采样设置,曲线是否闭合,生成的网格,起始位置是否需要封口等等。 返回值 BufferGeometry

/**
 * 
 * curve1主路径,curve2在路径上采样,那么对每个curve2 都要进行管道或者挤出那种运算
 * Create a BufferGeometry surface between two curves
 * @param {Curve} curve1 - First curve (base curve)
 * @param {Curve} profileCurve - Second curve (contour curve)
 * @param {{segments1:number, segments2:number, closed1:boolean, closed2:boolean}} options - Optional configuration
 * @param {number} [options.segments1=32] - Number of segments to sample along the curves
 * @param {number} [options.segments2=32] - Number of segments to sample along the curves
 * @param {boolean} [options.closed1=false] - Whether the curves form a closed loop
 * @param {boolean} [options.closed2=false] - Whether the curves form a closed loop
 * @param {boolean} [options.cover=false] - 封口
 * @returns {BufferGeometry} Generated geometry between the curves
 */
export function curvesToMeshGeometry(curve, profileCurve, options = {}) {
  const { segments1 = 32, segments2 = 32, closed1 = curve.closed, closed2 = profileCurve.closed,cover =false } = options;

  // Validate input curves
  if (typeof curve.getPoint !== "function" || typeof profileCurve.getPoint !== "function") {
    throw new Error("Input must be Three.js Curve instances");
  }
  const geometry = new BufferGeometry();  
  return geometry;
}

采样设置主要有采样点数、曲线是否闭合。

  const mainPoints = curve.getSpacedPoints(segments1);
  const profilePoints = profileCurve.getPoints(segments2);

除了点位信息,我们还需要切线,法线 副法线(直译),这里的法线和副法线是平行传输架构实现的需要,就是用来解决曲线上的旋转问题。可以按照lookAt那种xyz三轴来理解。 暂且把切向看做z轴。

点分布于线上 实例化于点上

路径曲线采样(点),就是点分布于线上,然后,在这每个点上,去实例化我们的轮廓曲线。 当然,实际上是实例化轮廓曲线采样后的点。 这样,全部的顶点就有了。 这个实例化于点上,可以看做是位移

就是在mainPoints的每一个点上,都放一组profilePoints。

  let basePoint = new Vector3();
  // 分段数为1 有两个点, 现在是 (segments1 + 1)*(segments2 + 1) 个点
  for (let i = 0; i <= segments1; i++) {
    //   内循环轮廓曲线,轮廓曲线只需要采样一次就可以了
    for (let j = 0; j <= segments2; j++) {
      basePoint.copy(mainPoints[i]);
      v1.copy(profilePoints[j])
      basePoint.add(v1);
      vertices.push(basePoint.x, basePoint.y, basePoint.z);
    }
  }

旋转对齐

仅仅这样的话,结果大概率会是下面这样,上一篇文章里也提过。所以,我们还需要对齐旋转,对齐的是切向和法向。具体原理就是这个平行传输

curve2meshPoint.webp

下面的代码也是参考了 three的Curve的实现方式,在我看来 ,就是把 切向作为z轴、法向副法线作为 yx之后,得到了新的坐标系下的坐标。

这里还处理了uv,其实就是保证u和v的相对单位一致。u在路径曲线上增长,v在轮廓曲线内增长 。

...
 const frames = curve.computeFrenetFrames(segments1, closed1);
   // 假设路径曲线长于轮廓曲线  这里为了路径闭合的时候 贴图无缝,取整了
  const Uratio = Math.floor(pathLength / profileLength);
 ...
  let basePoint = new Vector3();
  // 分段数为1 有两个点, 现在是 (segments1 + 1)*(segments2 + 1) 个点
  for (let i = 0; i <= segments1; i++) {
    //   内循环轮廓曲线,轮廓曲线只需要采样一次就可以了
    for (let j = 0; j <= segments2; j++) {
     basePoint.copy(mainPoints[i]);
      v1.copy(profilePoints[j]);
      normal.copy(frames.normals[i]).multiplyScalar(-v1.y);
      bnormal.copy(frames.binormals[i]).multiplyScalar(v1.x);
      // 默认轮廓曲线是在xy 平面的 也就是默认基准轴是z轴
      basePoint.add(normal).add(bnormal);
      vertices.push(basePoint.x, basePoint.y, basePoint.z);
      // 计算uv   能保证uv 是均匀的 但是,如果路径曲线是封闭的,uv贴图不能无缝了 
      uvs.push(us[i] * Uratio, vs[j] );
    }
  }

好,看到这里,其实,你就实现了一个类似ExtrudeGeometry的东西。所以,前面就说了,我发明了一把雨伞。

不过,还好,three的雨伞,略有不足之处 ,我还可以加两个东西,曲线半径,和曲线倾斜。

曲线半径

首先,需要说明的是,曲线半径和曲率半径没有一毛钱的关系哦。 曲线半径 ,就是缩放的意思,1的时候,表示不缩放。请看动画。图中,是一条直线曲线 ,默认每个点的曲线半径都是1,然后,我对某点的半径进行放大,可以看到,管道的粗细变了。所以 ,要实现曲线半径,也十分的简单,只要对轮廓曲线进行缩放即可。

curveRadius.webp 只需要在上面的代码中,乘上一个曲线半径即可。曲线采样的点,每个点都对应一个半径。

当时,实现这里的时候,我也比较犯难。 可以看到,曲线半径这个数据,应该是属于曲线的,它并不属于曲线转网格这个方法。

curveTilt.webp 但是,three的曲线上是没有这个属性的,当然可以去原型上挂一个。最后,我决定使用外挂式实现。在需要曲线半径的曲线上,挂载一个获取曲线半径的方法。

  let radiusArr = new Array(segments1 + 1).fill(1);
  if (typeof curve.getRadiusArray === "function") {
    radiusArr = curve.getRadiusArray(segments1);
  }
  ...
  // for 循环中
   v1.copy(profilePoints[j]).multiplyScalar(radiusArr[i]);

curveTilt.webp

曲线倾斜

曲线倾斜,就是让轮廓曲线绕着路径曲线的切线旋转。请看动画。 curveTilt.webp

实现起来也是相当简单 ,旋转轴和角度都知道了。只需把上面的副法线和法向旋转一下即可。

这里的法线和副法线,是绝对坐标,不是相对坐标,所以没法直接用二维的旋转公式简化计算。

  let tiltArr = new Array(segments1 + 1).fill(0);// 曲线倾斜
...
//for 循环内
  if (typeof curve.gettiltArray === "function") {
    tiltArr = curve.gettiltArray(segments1);
  }
 q1.setFromAxisAngle(frames.tangents[i], tiltArr[i]);
      normal.copy(frames.normals[i]).applyQuaternion(q1).normalize().multiplyScalar(-v1.y);
      bnormal.copy(frames.binormals[i]).applyQuaternion(q1).normalize().multiplyScalar(v1.x);
      
         

同样,这里的曲线倾斜 ,同样属于曲线,我同样使用外挂式实现。

案例

有了曲线转网格,只需要自定义曲线,便能做出多种形状。比如,下面这个莲花,就全都是曲线转网格做的。当然,多个莲花莲子的摆放是靠实例化于点上做的,所有的单体形状都是曲线转网格。

暂时没法嵌入到码上掘金的 iframe里面,所以请移步欣赏莲花节点 莲花.webp

莫比乌斯环,就得上曲线倾斜。 image.png

这个角是从某个龙建模教程里拆出来的,也有群友说,这个像金属软管。 image.png