坦克的履带

417 阅读7分钟

先看效果

tank.webp

这是在blender里做出来的。用的是几何节点,而且没用到什么难以实现的几何节点,再加上,我们之前已经实现了曲线相关逻辑,所以在three里肯定是能实现的。

image.png

既然坦克的履带都要做了,那就简单做个坦克示意一下。

所以我们的坦克= 炮管 + 身子 + 履带。

tank.webp 整个流程比较繁琐 ,这里就只介绍一下关键代码,主要是思路。

履带

履带是第三简单的,所以先做履带。

image.png

先不考虑直接从几何节点转代码,我们从效果本身来拆解一下。

首先,模型本身,这个履带是由多个相同的零件组成的,这些零件分布在一个近似椭圆的曲线上,而且每个零件都和这个椭圆是相切的。

然后,模型动画,动画就是零件沿着这个曲线运动的。说到沿曲线运动,想必大家就知道接下来如何做了。

建模

初始化场景后,先来一个地面参照物。

image.png

零件

前面说了,履带是零件组成的,所以,先得做出一个零件。可以看到,这个零件就是凸字型,然后下面又凹进去了。像掌机里面的坦克,还有俄罗斯方块最烦的形状。 这个形状还是很简单的,我们用canvas2d绘制出来,然后挤出即可。

这里,就不用曲线转网格了,因为倒角还没实现。 倒角虽然用曲线半径也能实现,但是会比较费顶点。

image.png image.png

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即可,具体参数效果,玩一玩就知道了。

extrude.webp

唯一需要注意的是,我们的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;

组装

单个零件有了,现在把零件串起来到,串到一条曲线上。其实就是,和之前曲线转网格核心逻辑一样。

image.png

所以,先来一条曲线,表示履带的基本形状,我们用四个点就可以搞定这条曲线。

先在曲线上采样点,这样我们就知道了每个零件的位置,这个简单。

然后,根据曲线的切向,让零件对齐,也就是算出旋转,前面就说了,我们这个形状,它是在xyo平面内,z方向的厚度。也就是说,相较于要分布的曲线,这个零件的初始轴向应该是Y轴。

我们使用用CatmullRomCurve3曲线,四个点的话,底边就不是平直的了,需要加一个点控制一下。

image.png

  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;
}

思路就是这么个思路。 至于里面填充的那个面,就是用曲线挤出了一个形状,不然确实空了一点儿 ,我懒得再做轮子。

image.png

实例化于点上

这里,我们还可以做一个优化,就是使用实例化。因为零件都一样,材质也一样,非常适合实例化。 基本逻辑是一模一样的。

实例化网格的使用在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 的探索之旅中提供一些有益的参考和启发。