弯曲的绳子
上一次,我们成功地搓出一根绳子,但是只能是直的,这显然不人性化。
先回忆一下在Blender里绳子怎么搓。
再看Blender里是如何弯曲绳子的。
可以看到要弯曲一根绳子,有以下步骤。
- 首先要有一根绳子和目标曲线
- 给绳子添加曲线修改器,设置目标曲线
- 将轴向改为Y轴,这个Y轴,就是之前搓绳子的方向。
直的绳子,上一回搓绳子已经说过了,最终采用了管道的方式去做,更灵活。
所以,要把一条直的绳子扭曲成某个形状,还差个形状。这个形状就是路径曲线。我已经准备好了一个螺旋曲线,类似上图那种。然后给之前的绳子应用上我们的螺旋曲线的形状 。
没错,关键还是曲线。 有了目标曲线,我们就可以把绳子贴合到曲线上,在Blender里这叫曲线修改器。
曲线修改器
那么,我们要实现任意形状的绳子,也需要实现一个曲线修改器吗?
当然得实现一个,但只需要实现部分功能。
那么three中是否有类似曲线修改器的东西呢?一开始我没想到, 后来按自己的想法失败了之后,突然想到管道几何体本身就是在做和曲线修改器一样的事情。
相较于管道,挤出几何体,更具有一般性。
下定义
blender里的曲线修改器是现成的,但是three的曲线修改器还没有定义。没有定义的问题,便无法具体解决。
我们来尝试定义一下。 首先有两个要素,一是待扭曲的物体比如下面这把星寒剑,二是要扭曲成的形状曲线,就是最终缠绕在金臂童手上的剑的剑脊的路线。
有了曲线和几何体之后,我们希望把几何体串到曲线上。 所以我们的曲线修改器就三步。
- 准备一个几何体,比如直的绳子。
- 准备一条曲线,比如螺旋曲线。
- 把几何体扭曲成曲线的形状。
如何扭曲
我相信前两步肯定难不住各位工程师。
这里就用上次搓的绳子,以及略微改动后的螺旋曲线。
第三步,我们需要再细化一下。
所谓扭曲,在我看来,就可以分解为位移和旋转。
确定方向
在扭曲之前,我们还需要定义起点和终点。threejs的曲线,大部分情况下,取点是按百分比取的,所以曲线的两点有现成的,那么物体就需要我们来定义了。
比如一根绳子,从哪头儿开始,到哪里结束。其实就是要确定方向,绳子的初始方向。 比如,上次我们搓的绳子,方向就是Y轴。 方向确定了是Y轴,起点自然就是Y值最小的那个点。
至于终点,我们暂且给定一个系数。 因为曲线上的点应该是要和几何体轴向上的点一一对应的,但是很多时候并不会这么完美。
比如,我们现在定义了一个螺旋曲线,其总长是2π,但是我们现在要扭曲的绳子只有2m,怎么办? 拉伸吗,也可以,但是绳子肯定会变形。 所以我的办法是暂且乘以一个系数,可体以通过调节这个系数,达到我们想要的效果,拉伸也在我们可以接受的范围内。
先不讨论边界情况了,来看看,有了方向之后,要如何位移以及旋转。
位移
刚才设定了绳子的本身的方向是Y轴,所以,要找到绳子上的顶点对应曲线上的点,这里偷个懒,就取它Y值即可。
多说一句,不管什么形变,本质还是计算顶点形变后的坐标,如果不涉及顶点数量的变化。
假定我们现在就一比一,那么Y值乘的系数就是1,只要除以曲线的总长度,就可以得到百分比,进而取得曲线上的点。
当然,我们得遍历绳子所有的顶点, 然后计算百分比,计算位移量。
这个位移不是直接把绳子的顶点移到曲线上,而是把绳子的轴上的点移到曲线上,绳子上所有的点都进行这样的位移。
就像果实挂在树上,结果树枝被压弯了,那果实自然也就跟着动了。
const positions = geometry.attributes.position.array;
let totalLength = curve.getLengths().at(-1);
const v1 = new Vector3(),
v2 = new Vector3(),
v3 = new Vector3(),
v4 = new Vector3();
const q1 = new Quaternion();
let alpha = 0;
const yAxis = new Vector3(0, 1, 0);
let dist = 0;
for (let i = 0; i < positions.length; i += 3) {
// 取出几何体的顶点
v1.set(positions[i], positions[i + 1], positions[i + 2]);
// 直接用点乘, 计算出在轴上的距离
dist = v1.dot(axis) * factor + offset
alpha = dist /totalLength;
curve.getPoint(alpha, v2);
// 轴上对应的点
v3.clone(axis).multiplyScalar(dist);
// 轴上对应的点,位移到在曲线上的点
v2.sub(v3);
// 几何体上的点,加上这个位移就是目标点
v1.add(v2);
positions[i] = v1.x;
positions[i + 1] = v1.y;
positions[i + 2] = v1.z;
}
似乎,仅仅是移动顶点到曲线上,就达到了效果。 但是转动相机,你会发现,这绳子似乎瘪了。因为原本,绳子的截面都是水平面,现在直接把这些截面移到一个螺旋曲线上,依然还是水平的,尤其是在12和6点钟方向,看起来非常扁。
旋转
位移的依据是原本的顶点到曲线上的点,旋转的依据则是,原本的轴向到曲线上的点的切向。如何理解这个旋转。
我的理解是,一根直线(初始绳子的轴)上面长满了尖刺,钉满了钢针,都垂直于直线。当这根线弯曲的时候,会带动上面的钢针运动。不管如何运动,这钢针始终保持和线的切向垂直,钢针的长度不变。
钢针的尖端就相当于我们的绳子的顶点,顶点和轴一直保持着这样一种相对关系。
所以,现在就是要取得曲线上每一点的切向。
three的Curve上是有计算切线方向的,默认的计算方式也十分简单。
就是在当前点的前后取两个点,后减前。 如果诸位自定义的曲线,其切向可以直接用公式算出来,那是再好不过了。
轴向知道,切向也知道,那么从轴向旋转到切向,这样一种旋转如何应用到顶点上呢?
显然,从向量A旋转到向量B,显然,我们直接用四元数。
这个旋转实际上是以钢针的根部(垂足)作为球心,钢针长度作为半径,进行旋转的。所以,只要把钢针向量应用这个旋转,再加上上面的位移就成了。
这里我的思路实际上是和TubeGeometry一样,已经知道轴最终的形状,根据顶点和轴的相对位置,计算出顶点新的位置。
但是有一些细节不同,因此埋下了祸根,此处按下不表,文末会详述。
以下,就是完整的曲线修改器的代码,本文的核心。法线的旋转和钢针的旋转量是一样的,不再赘述。
function curveModifier(
geometry = new BufferGeometry(),
axis = "y",
curve,
offset = 0,
factor = 1
) {
const axisMap = {
x: new Vector3(1, 0, 0),
y: new Vector3(0, 1, 0),
z: new Vector3(0, 0, 1),
};
const positions = geometry.attributes.position.array;
const normalAttr = geometry.attributes.normal;
// 遍历顶点数据,按轴向,对应曲线距离。 往曲线上偏移,同时偏移其法线,uv不管了。
const v1 = new Vector3(),
v2 = new Vector3(),
v3 = new Vector3(),
v4 = new Vector3();
const q1 = new Quaternion();
let alpha = 0;
const dir = axisMap[axis];// 基准轴向
const vertices = [],
normals = [];
let dist = 0;
for (let i = 0; i < positions.length; i += 3) {
v1.set(positions[i], positions[i + 1], positions[i + 2]);
alpha = v1[axis] * factor + offset;
// 原本的点到基准轴的垂线
v4.copy(v1);
v4[axis] = 0;
dist = v4.length();
curve.getPoint(alpha, v2);
curve.getTangent(alpha, v3);
q1.setFromUnitVectors(dir, v3);
// 旋转钢针
v4.applyQuaternion(q1);
v4.normalize();
v1.addVectors(v2, v4.multiplyScalar(dist));
vertices.push(v1.x, v1.y, v1.z);
if (normalAttr) {
v4.set(
normalAttr.array[i],
normalAttr.array[i + 1],
normalAttr.array[i + 2]
);
v4.applyQuaternion(q1).normalize();
normals.push(v4.x, v4.y, v4.z);
}
}
const geo = geometry.clone();
// 这样就只修改了顶点和法线,其余不变。
geo.setAttribute("position", new Float32BufferAttribute(vertices, 3));
if (normalAttr) {
geo.setAttribute("normal", new Float32BufferAttribute(normals, 3));
}
return geo;
}
螺旋状的绳子
我已经准备好了一个螺旋曲线,和之前搓绳子用的螺旋曲线有所不同,半径是随螺旋角度而递增的。
export class CustomSinCurve extends Curve {
constructor(turn = 3 , depth = 1,radius=1,axis = 'z') {
super();
this.turn = turn;
this.depth = depth;
this.axis = axis;
this.radius = radius;
}
getPoint (t, target = new Vector3()) {
// x y 分别取正余弦 可不就是圆
let x = t * sin(t * PI * 2 * this.turn)* this.radius;
let y = t * cos(t * PI * 2 * this.turn)* this.radius;
let z = t * this.depth;
return target.set(x, y, z);
}
}
用上我们的曲线修改器,看看效果。
因为之前绳子是用两个管道做的,所以得分别对两个几何体使用我们的曲线修改器。结合之前的代码,我简单封装了一下。
function createRope(
spiralRadius = config.spiralRadius,
spiralTheta = config.spiralTheta,
spiralDepth = config.spiralDepth,
thetaOffset = config.thetaOffset,
tubeSegments = config.tubeSegments,
tubeRadius = config.tubeRadius,
tubeRadiusSegments = config.tubeRadiusSegments,
cover = true,
close,
curve,
mat = spiralMat,
axis = "y",
) {
const spiralGeo1 = generateSpiralGeo(
spiralRadius,
spiralTheta,
spiralDepth,
thetaOffset,
tubeSegments,
tubeRadius,
tubeRadiusSegments,
cover,
close
);
const spiralGeo2 = generateSpiralGeo(
spiralRadius,
spiralTheta,
spiralDepth,
thetaOffset + PI,
tubeSegments,
tubeRadius,
tubeRadiusSegments,
cover,
close
);
if( axis === "x" ) {spiralGeo1.rotateZ(PI*-.5);spiralGeo2.rotateZ(PI*-.5)}
if( axis === "z" ) {spiralGeo1.rotateX(PI*.5);spiralGeo2.rotateX(PI*.5)}
const ropeGeo1 = curveModifier(spiralGeo1, axis, curve, 0, .1);
const ropeGeo2 = curveModifier(spiralGeo2, axis, curve, 0, .1);
const rope = new Group();
rope.add(new Mesh(ropeGeo1, mat), new Mesh(ropeGeo2, mat));
return rope;
}
效果还不错吧。
下面是一堆绳子。
曲线修改器还能做什么
目前我们用曲线修改器来弯曲绳子,可谓绳子生成器了。当然,曲线修改器不只可以用来扭曲绳子,还有很多应用的地方,比如, 还可以做弯曲的牛角,牛角如果不弯曲,可以简化为一个圆锥,所以只要对圆锥做弯曲就能做出弯曲的牛角、羊角、号角等等。
不过,我更想做一柄剑,宁折不弯的剑。
造剑
因为,我要做的是飞剑,所以剑柄就没用了。
这柄剑做起来十分简单,用CylinderGeometry就可以搞定了。将剑分为剑尖(四角圆锥)和剑身(四棱柱)。
鉴于,后面还要用曲线修改器,这里为了方便,直接合并几何体。
const swordBody= new CylinderGeometry(1, 1, 10,4, 100, true, 0);
const swordTop= new CylinderGeometry(0, 1, 1,4, 1, false, 0);
swordTop.translate(0, 5.5, 0);
const swordGeo = mergeGeometries([swordBody, swordTop], { dispose: true });
const sword = new Mesh(swordGeo, pbrMat);
这样剑的基本形状就出来了。当然,没有这么胖的剑,快成锏了。我们就在x方向上缩小一些,同时换上一个金属材质。
由于降低了粗糙度,所以我们需要一个环境贴图,这样能保证任意视角都能有光线进入眼睛。
const metalMat = new MeshPhysicalMaterial({ roughness: 0.1, flatShading: true, metalness: 1 });
swordGeo.scale(0.5, 1, 0.2)
const sword = new Mesh(swordGeo, metalMat);
异形剑
剑体有了,然后对其使用我们的曲线修改器,曲线先来个简单的圆吧。 效果如下。
curveModifier(swordGeo, 'y', circleCurve, config.offset, config.factor);
再来一个心形的,剑身长度不够,让飞剑动起来。
curveModifier(swordGeo, 'y', circleCurve, config.offset, config.factor);
曲线的连续性
文章写到这里,曲线修改器的核心讲的差不多了。现在把坑说一下,前面实现的曲线修改器是有一些bug的。只要没人用,就没有bug。
原因就是,我在做切向方向的旋转的时候,直接把原本的轴向转向了切向,似乎没什么问题。
但是,请看下图。 绳子在每一圈180度附近就变形了。
这个问题能解决吗?我想是可以的,因为人家Blender就没有这个问题。
一开始我甚至不知道哪里出问题了,一直以为是旋转计算的不对,后来又想起来,TubeGeometry和我现在做的事情类似, 它就没有这个问题,又去看了一下它的计算逻辑,发现了这个Parallel Transport Approach to Curve Framing。
TubeGeometry的计算逻辑和我不同的地方在于,它不是直接用初始轴到切向的旋转来旋转,而是用上一个切向到下一个切向的旋转来累计旋转,这样曲线的连续性就有保证了(我猜的)。
虽然我大概看懂了整个计算过程,但是这个曲线修改器的bug,到目前为止还没解决,期待有高手来解决一下。