前言
之前我们已经实现过了曲线修改器,曲线转网格的核心逻辑和它并没有区别。
之前的曲线修改器,即使使用了平行传输架构,仍有一些问题。主要原因是,我在three中定义的曲线是无限(特指采样范围超出了0-1)的,blender的做法是在超出之后按直线处理。
而且,之前的曲线修改器(几何版)本来也不适合用作曲线偏移动画,three/example中的着色器版本的曲线修改器是比较适合用作曲线动画的。
我这个曲线修改器就是建模用的。同样,今天这个曲线转网格也是建模用的,虽然说也能用作动画,但是,如果需要曲线动画,最好是在着色器里处理。
还有一件事,在实现这个曲线转网格的过程中,我就发现,自己似乎正在发明雨伞。因为,ExtrudeGeometry的核心逻辑正是曲线转网格,人家已经实现的很好了,只是曲线转网格更具有一般性。
因此,如果你知道什么是曲线转网格,而且也了解three的挤出几何体,那么完全可以直接在挤出几何体的基础上实现这个,可以直接看曲线半径和曲线倾斜的实现。
一、概念溯源:Blender的曲线建模
Blender的"曲线转网格"(Curve to Mesh)本质是参数化建模范式的典型应用:
• 输入:两条曲线
• 路径曲线(Path Curve):定义几何体的中心走向
• 截面曲线(Profile Curve):定义横截面形状
• 输出:通过沿路径扫描截面的方式生成网格
- 使用方法,对任意网格体使用修改器-几何节点,新建一个几何节点,就可以开始了。
按F3,即可搜索“曲线转网格”,当然也可以搜索其他节点。这样就用两个简单的曲线来示范一下。
二、Three.js实现
曲线转网格大概就分为以下几步。
- 定义路径曲线
- 定义轮廓曲线
- 采样路径曲线得到一堆点,以及点上的切线等信息
- 采样轮廓曲线,同样得到一组点数据,备用。
- 在每个路径曲线的点上,都放置一遍全部的轮廓曲线的点,这便是我们最终需要的顶点数据。
- 按照一定的顺序将这些点组装为三角面。
严格来说,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);
}
}
旋转对齐
仅仅这样的话,结果大概率会是下面这样,上一篇文章里也提过。所以,我们还需要对齐旋转,对齐的是切向和法向。具体原理就是这个平行传输
下面的代码也是参考了 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,然后,我对某点的半径进行放大,可以看到,管道的粗细变了。所以 ,要实现曲线半径,也十分的简单,只要对轮廓曲线进行缩放即可。
只需要在上面的代码中,乘上一个曲线半径即可。曲线采样的点,每个点都对应一个半径。
当时,实现这里的时候,我也比较犯难。 可以看到,曲线半径这个数据,应该是属于曲线的,它并不属于曲线转网格这个方法。
但是,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]);
曲线倾斜
曲线倾斜,就是让轮廓曲线绕着路径曲线的切线旋转。请看动画。
实现起来也是相当简单 ,旋转轴和角度都知道了。只需把上面的副法线和法向旋转一下即可。
这里的法线和副法线,是绝对坐标,不是相对坐标,所以没法直接用二维的旋转公式简化计算。
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里面,所以请移步欣赏莲花节点
莫比乌斯环,就得上曲线倾斜。
这个角是从某个龙建模教程里拆出来的,也有群友说,这个像金属软管。