先看效果
这是在blender里做出来的。用的是几何节点,而且没用到什么难以实现的几何节点,再加上,我们之前已经实现了曲线相关逻辑,所以在three里肯定是能实现的。
既然坦克的履带都要做了,那就简单做个坦克示意一下。
所以我们的坦克= 炮管 + 身子 + 履带。
整个流程比较繁琐 ,这里就只介绍一下关键代码,主要是思路。
履带
履带是第三简单的,所以先做履带。
先不考虑直接从几何节点转代码,我们从效果本身来拆解一下。
首先,模型本身,这个履带是由多个相同的零件组成的,这些零件分布在一个近似椭圆的曲线上,而且每个零件都和这个椭圆是相切的。
然后,模型动画,动画就是零件沿着这个曲线运动的。说到沿曲线运动,想必大家就知道接下来如何做了。
建模
初始化场景后,先来一个地面参照物。
零件
前面说了,履带是零件组成的,所以,先得做出一个零件。可以看到,这个零件就是凸字型,然后下面又凹进去了。像掌机里面的坦克,还有俄罗斯方块最烦的形状。 这个形状还是很简单的,我们用canvas2d绘制出来,然后挤出即可。
这里,就不用曲线转网格了,因为倒角还没实现。 倒角虽然用曲线半径也能实现,但是会比较费顶点。
2D形状
因为后面肯定要调节,所以,这里一开始就把这个图形给参数化。凸出来和凹进去的长度要相当,这样才能无缝衔接。
绘制结束后记得闭合曲线,因为一个完整的二维多边形肯定是封闭的路径。
/**
* 凸形单元
* @param width 宽度
* @param height 长度
* @param offset 凸起距离
* @param thickness 厚度
* @param factor 凸起占比
*
*/
function generatePart(width :number, height :number, offset:number, thickness:number, factor:number) {
const shape = new Shape();
shape.moveTo(-width , height );
shape.lineTo(-width*factor , height );
shape.lineTo(-width*factor , height + offset);
shape.lineTo( width*factor , height + offset);
shape.lineTo( width*factor, height );
shape.lineTo( width, height );
shape.lineTo(width , -height );
shape.lineTo(width*factor , -height );
shape.lineTo(width*factor , -height + offset );
shape.lineTo(-width*factor , -height + offset );
shape.lineTo(-width*factor , -height );
shape.lineTo(-width , -height );
shape.closePath();
未完待续
挤出
使用ExtrudeGeometry 即可让2d形状变得立体、有厚度,depth就是厚度。还可以加倒角,使用非常简单。
默认是有倒角的,如果不需要倒角,将bevelEnabled设为false即可,具体参数效果,玩一玩就知道了。
唯一需要注意的是,我们的2d截面是在xyo平面内,挤出的方向是沿z轴的。
最后,因为我的二维路径,设计的时候,没有按照几何中心在原点设计,所以,生成几何体,又调用了一个center方法,最好是二维路径就设计好,或者center一下,消耗少很多。
至于为什么,要让这个几何体的中心和原点重合。大伙儿可以试试不在原点会怎样。这主要是方便接下来的沿曲线分布。
续接上文
const geo = new ExtrudeGeometry(shape, { depth: thickness,
bevelEnabled:true,
bevelSegments:1,
bevelSize:0.01,
bevelThickness:0.01,
bevelOffset:0,
});
geo.center()
return geo;
组装
单个零件有了,现在把零件串起来到,串到一条曲线上。其实就是,和之前曲线转网格核心逻辑一样。
所以,先来一条曲线,表示履带的基本形状,我们用四个点就可以搞定这条曲线。
先在曲线上采样点,这样我们就知道了每个零件的位置,这个简单。
然后,根据曲线的切向,让零件对齐,也就是算出旋转,前面就说了,我们这个形状,它是在xyo平面内,z方向的厚度。也就是说,相较于要分布的曲线,这个零件的初始轴向应该是Y轴。
我们使用用CatmullRomCurve3曲线,四个点的话,底边就不是平直的了,需要加一个点控制一下。
const caterpillarCurve = new CatmullRomCurve3([
new Vector3(0,0.1,caterpillarParams.lenX - 0.3),
new Vector3(0,0,0),
new Vector3(0,0.1,0.3- caterpillarParams.lenX),
new Vector3(0,caterpillarParams.height,-caterpillarParams.lenX),
new Vector3(0,caterpillarParams.height,caterpillarParams.lenX),
],true,'chordal');
曲线有了,零件有了,接下来,就是沿曲线分布了。还是那个逻辑,取点,按切向对齐旋转。 这里,直接用了四元数,setFromUnitVectors,实际上可能会有问题 ,目前能正常工作,是因为初始轴是Y,切向也都在yzO平面内,所以计算出的旋转轴一定是X轴。
然后,调节一下count参数,使得我们的零件刚好充满整个曲线。
/**
* 沿着曲线分布mesh
* @param curve 曲线
* @param mesh 要分布的mesh
* @param count 分布数量
* @param axis 初始朝向轴
* @param group 可选,要添加到的组
*/
export function distributeAlongCurve(
curve: Curve<any>,
mesh: Mesh,
count: number,
axis: keyof typeof axisMap = "z",
group?: Group
): Group {
const g = group || new Group();
const segments = count - 1;
const baseAxis = axisMap[axis];
for (let i = 0; i <= segments; i++) {
const t = i / segments;
const position = curve.getPointAt(t);
const tangent = curve.getTangentAt(t).normalize();
// 创建mesh副本
const meshCopy = mesh.clone();
meshCopy.position.copy(position);
// 设置朝向
const quaternion = new Quaternion().setFromUnitVectors(baseAxis, tangent);
meshCopy.quaternion.copy(quaternion);
g.add(meshCopy);
}
return g;
}
动起来
上面已经实现了曲线分布,动起来就简单了,只需要加一个偏移量。
这里就假设入参group下面的子节点,就是要在曲线上运动的mesh。把上面的函数稍稍改造 一下。变量名什么的懒得改了。
主要就是加了一个offset参数,在曲线采样的时候偏移一下,来实现在曲线上偏移。
/**
* 沿着曲线运动
* @param curve 曲线
* @param axis 初始朝向轴
* @param group 可选,要添加到的组
* @param offset 偏移量
*/
export function moveAlongCurve(
curve: Curve<any>,
axis: keyof typeof axisMap = "z",
group?: Group,
offset = 0
): Group {
const g = group ;
const count = group.children.length;
const segments = count - 1;
const baseAxis = axisMap[axis];
for (let i = 0; i <= segments; i++) {
const t = i / segments + offset;
const position = curve.getPointAt(t);
const tangent = curve.getTangentAt(t).normalize();
const meshCopy = g.children[i];
meshCopy.position.copy(position);
// 设置朝向
const quaternion = new Quaternion().setFromUnitVectors(baseAxis, tangent);
meshCopy.quaternion.copy(quaternion);
}
return g;
}
思路就是这么个思路。 至于里面填充的那个面,就是用曲线挤出了一个形状,不然确实空了一点儿 ,我懒得再做轮子。
实例化于点上
这里,我们还可以做一个优化,就是使用实例化。因为零件都一样,材质也一样,非常适合实例化。 基本逻辑是一模一样的。
实例化网格的使用在three里面也非常简单。
const instancedMesh = new InstancedMesh(
geo = new BufferGeometry(),
mat = new MeshBasicMaterial(),
count = 100
);
然后,按照上面的逻辑更新矩阵,所以得把上面的位置和旋转组装为矩阵。其它的逻辑就没变化了。
function moveAlongCurve2(
curve: Curve<any>,
instancedMesh: InstancedMesh,
axis: keyof typeof axisMap = "z",
offset = 0
): void {
const count = instancedMesh.count;
const segments = count - 1;
const baseAxis = axisMap[axis];
const matrix = new Matrix4();
const position = new Vector3();
const quaternion = new Quaternion();
const scale = new Vector3(1, 1, 1);
for (let i = 0; i < count; i++) {
const t = (i / segments + offset) % 1;
curve.getPointAt(t, position);
const tangent = curve.getTangentAt(t).normalize();
// 设置朝向
quaternion.setFromUnitVectors(baseAxis, tangent);
// 更新矩阵
matrix.compose(position, quaternion, scale);
instancedMesh.setMatrixAt(i, matrix);
}
// 更新实例
instancedMesh.instanceMatrix.needsUpdate = true;
}
身子
既然先做了履带,那就根据履带来调节身子。这里很简单,就拿一个球当做身体,所以球至少离地面一个半径的高度。
const bodyMat = new MeshStandardMaterial({color:0x6688aa,metalness:1,side:DoubleSide})
const body = new Mesh(new SphereGeometry(1), bodyMat)
body.castShadow =true
炮管
炮管就是一个管,用CylinderGeometry或者TubeGeometry,用圆柱是很方便的。
const tube = new CylinderGeometry(0.15,0.15,2.5,6,1,true) ;
tube.rotateX(PI/2) ;
tube.translate(0,0,1.25) ;
const tubeMesh = new Mesh(tube, bodyMat) ;
tubeMesh.castShadow =true ;
收尾
坦克本身没啥难度,除开控制,其实控制也不难,只要没有轮子。今天主要是分享一下,沿曲线分布和在曲线上运动,所以其它的部分并不重要。
轮子有什么难的呢?那就是轮子的转速和坦克的移速要相适应,这个理论上用线速度换算角速度就没问题了。
但是,转弯就有点儿麻烦,转弯的时候轮子也得转啊,但是转速就不一样了。
履带反而方便一些,只要两个履带往相反的方向旋转,就能转弯,而且这样,整个坦克的旋转中心就在几何中心。 希望这篇文章能够为诸位在 Three.js 的探索之旅中提供一些有益的参考和启发。