记一次笔记,初学three.js,坦克练习

872 阅读8分钟

这里我们来实现一个坦克,那么这个坦克的结构为

  • 场景
    • 坦克
      • 坦克主体,也就是驾驶舱,我们把所有的配件都装到驾驶舱上
        • 六个轮子
        • 炮台
          • 炮筒
            • 炮筒上面放个摄像机
        • 坦克上面放个摄像机
    • 两个灯光
      • 一个从顶部照射
      • 一个从正Z方向照射来补光,要不然一个灯太暗了
    • 地面和坦克运行路线
    • 坦克的目标

开头老样子,咱们建场景对象、摄像机对象、渲染器对象、渲染器函数、响应函数、罗里吧嗦的,创建一个main函数,作为程序入口。

渲染器函数加了一个time参数,为当前渲染器从创建到现在的时间,会逐步增加的,自己测一下。

let scene = new THREE.Scene();
let camera = new THREE.PerspectiveCamera(
  45,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
camera.position.z = 50;
let c = document.getElementById("c") as HTMLCanvasElement;
let renderer = new THREE.WebGLRenderer({
  canvas: c,
});
renderer.setClearColor("#C4C4C4");

function ResetSize() {
  let ratio = window.devicePixelRatio
  let w = c.clientWidth * ratio;
  let h = c.clientHeight * ratio;
  let needReset = c.width !== w || c.height !== h;
  if (needReset) renderer.setSize(w, h, false);
  return needReset;
}

function render(time) {
  requestAnimationFrame(render);
  if (ResetSize()) {
    const canvas = renderer.domElement;
    camera.aspect = canvas.clientWidth / canvas.clientHeight;
    camera.updateProjectionMatrix();
  }
  renderer.render(scene, camera);
}

function main() {
}
main();
requestAnimationFrame(render);

坦克壳子

这里的壳子也就是说是坦克外面包的Object3D,为的就是把坦克的东西给包起来,形成一个整体。

我懒,这里把坦克画成一个颜色的。所以我把TankMater提出来不用每次都创建了

let wireframe = false;
let TankMater = new THREE.MeshPhongMaterial({
  color: "#68663D",
  wireframe,
});
function createTank() {
  let Tank = new THREE.Object3D();
  // 这个Body其实就是一个板子我们稍候来看一下
  let TankBody = new THREE.BoxGeometry(15, 1.5, 8);
  let TankBodyMesh = new THREE.Mesh(TankBody, TankMater);
  Tank.add(TankBodyMesh);
  return Tank;
}
function main() {
  scene.add(createTank());
}

创建灯光,要不然太黑了

记得一定要有创建Object3D的习惯,把你认为是一体的部分包起来,但是具体要不要包,还得看实际情况。

function createLight() {
  let light = new THREE.Object3D();
  let topLight = new THREE.DirectionalLight("", 1);
  topLight.position.set(0, 50, 0);
  light.add(topLight);
  let zIndexLight = new THREE.DirectionalLight("", 1);
  zIndexLight.position.set(5, 5, 5);
  light.add(zIndexLight);
  return light;
}
function main() {
  scene.add(createTank(), createLight());
}

好了,我们现在创建完灯光了,我们可以看到一个野战军绿颜色的板子,我们拿他来当坦克的主体部分,作为坦克的基础结构。

创建地面

地面就是一个简简单单的正方形,比较矮的正方形。

function createGround() {
  let Ground = new THREE.BoxGeometry(60, 1, 60);
  let GroundMater = new THREE.MeshPhongMaterial({
    color: "#67ABB8",
    wireframe,
  });
  let GroundMesh = new THREE.Mesh(Ground, GroundMater);
  GroundMesh.castShadow = true;
  GroundMesh.receiveShadow = true;
  return GroundMesh;
}
function main() {
  scene.add(createTank(), createLight(), createGround());
}

castShadowreceiveShadow两个属性接下来创建轮子的时候会讲

什么?看着难受?板板整整的?那换个方向

// 把相机定位到X:-50,Y:40,Z:45的位置,这样你的视角是在远处的左侧向上一点的位置
camera.position.set(-50, 40, 45);
// 从相机当前位置看向0,0,0坐标点,也就是场景的正中心
camera.lookAt(0, 0, 0);

可以参照这个改动一下X,Y,Z的位置,可以有更多视角哦~

什么?坦克那个板子有一半到地面里去了?

我们来吧Tank对象抬上来

为什么抬板子厚度的两倍?我们是为了给轮子留点空间

Tank.position.y = 3;

创建6个轮子

我们用圆柱来做轮子,比较矮的圆柱,只不过圆柱的的段数比较少,所以我们看起来像方的,然后我们让圆柱旋转180°,让他从横着的变成竖着的。

因为我们要在render方法中让轮子们旋转起来,所以我们把wheel提出来成为一个数组,然后我们通过计算来让一侧三个轮子。

let wheels: Array<THREE.Mesh> = [];
function createWheels() {
  let size = 2.5;
  for (let i = 0; i < 6; i++) {
    let wheel = new THREE.CylinderGeometry(size, size, 1.5, 8);
    let wheelMesh = new THREE.Mesh(wheel, TankMater);
    wheelMesh.rotation.x = Math.PI / 2;
    wheelMesh.position.set(
      (i % 3) * (size * 2) + -size * 2,
      0,
      i >= 3 ? -4 : 4
    );
    wheels.push(wheelMesh);
  }
  return wheels;
}

为了让轮子添加到tankBody内部,我们稍微改动一下createTank方法,并且为TankTankBody设置可以形成阴影和接收阴影。

function createTank(...child: Array<THREE.Object3D | Array<THREE.Object3D>>){
  // ...
  child.forEach((c) => {
    if (Array.isArray(c)) {
      c.forEach((cc) => {
        cc.castShadow = true;
        cc.receiveShadow = true;
        TankBodyMesh.add(cc);
      });
    } else {
      c.castShadow = true;
      c.receiveShadow = true;
      TankBodyMesh.add(c);
    }
  });
  // ...
}

castShadow:可以形成阴影

receiveShadow:可以接收阴影,也就是说在其上方节点形成的阴影会不会显示在当前节点上面。

对于Array<THREE.Object3D | Array<THREE.Object3D>>这行代码来说,我们知道Mesh是继承与Object3D的,所以我们根据里氏替换原则,我们可以将父类Object3D定为形参,意味着它可以接收所有继承自Object3D的子类实例。

为了让场景可以显示阴影我们还需要进行以下操作

// createLight 方法
zIndexLight.castShadow = true; // 允许该灯光照射出阴影
zIndexLight.shadow.mapSize.width = 2048; // 一个二维矩阵定义阴影贴图的宽度
zIndexLight.shadow.mapSize.height = 2048;// 定义高度

const d = 50;
// 以下距离表示从0点看往哪个方向的距离,现在看的距离是50,你看的越短,那么就显示的越少
// 被裁剪的也越多
// 阴影摄像机左侧距离,如果为0你会发现左侧被裁掉了,也就是不显示了
zIndexLight.shadow.camera.left = -d;
// 右侧距离
zIndexLight.shadow.camera.right = d;
// 顶部距离
zIndexLight.shadow.camera.top = d;
// 底部距离
zIndexLight.shadow.camera.bottom = -d;
// 阴影相机近端面,即这个阴影相机对于灯光坐标的位置,现在就是站在灯光往前加1坐标
zIndexLight.shadow.camera.near = 1;
// 阴影相机远端面,即可以看多远
zIndexLight.shadow.camera.far = 50;
// 阴影贴图偏差,在比较复杂的地方绘制阴影时,比如曲面,从标准的平面深度,添加/减去多少
zIndexLight.shadow.bias = 0.001;

// 渲染器
renderer.shadowMap.enabled = true; // 允许在场景中使用阴影贴图
// 使用百分比-接近滤波(PCF)算法过滤阴影地图,具有更好的软阴影,
// 特别是低分辨率下阴影的渲染,将会有更好的效果
renderer.shadowMap.type = THREE.PCFSoftShadowMap;

我们在render方法中添加代码让轮子转起来

function render(time) {
  // ...
  wheels.forEach((w) => (w.rotation.y += 0.1));
  // ...
}

到现在main方法就变成了

function main() {
  scene.add(
    createTank(createWheels()),
    createLight(),
    createGround()
  );
}

创建炮台

我们用球体形状,做一个半球体,扣在板子上,然后做成一个类似炮台一样的东西。

function createDome(...child: Array<THREE.Object3D>) {
  let domeObj = new THREE.Object3D();
  let dome = new THREE.SphereGeometry(5,20,20,0,Math.PI * 2,0,Math.PI / 2);
  let domeMesh = new THREE.Mesh(dome, TankMater);
  child.forEach((c) => {
    c.castShadow = true;
    c.receiveShadow = true;
    domeMesh.add(c);
  });
  domeObj.add(domeMesh);
  return domeObj;
}

发现对的不是太齐,咱们微调一下

// 方法createWheels
let y = size * 2 - 0.5;
wheelMesh.position.set(
    (i % 3) * (size * 2) + -size * 2,
    -1,
    i >= 3 ? -y : y
);
// 方法createTank
Tank.position.y = 3.5;

修改main方法

scene.add(
    createTank(createWheels(), createDome()),
    createLight(),
    createGround()
);

创建炮筒和炮台上的摄像头和坦克上的摄像头

创建炮筒,这只是个普通的长方体,只不过比较长,但是很矮,很低。

我们将BarrelObj对象定义在外面是因为我们一会要用到,在炮筒外面套一层壳子,是为了让炮筒元素向外平移一点,但是旋转还是按照原来的0,0,0坐标旋转。

可以自己将BarrelObj去掉,也就是不套壳子,然后尝试一下,你会发现旋转的会有偏差,但是现在先别在乎旋转,等一下用到BarrelObj对象时,你再尝试。

let BarrelObj: THREE.Object3D;
function createBarrel(...child: Array<THREE.Object3D>) {
  BarrelObj = new THREE.Object3D();
  let Barrel = new THREE.BoxGeometry(1.2, 1.2, 10);
  let BarrelMesh = new THREE.Mesh(Barrel, TankMater);
  BarrelMesh.position.set(0, 0, 8);
  BarrelMesh.castShadow = true;
  child.forEach((c) => {
    BarrelObj.add(c);
  });
  BarrelObj.position.set(0, 2, 0);
  BarrelObj.add(BarrelMesh);
  return BarrelObj;
}
// main 方法
scene.add(
	createTank(createWheels(), createDome(createBarrel()))
    // ...
)

添加炮台摄像头,咱们以后还需要创建别的摄像头,所以我们创建一个createCamera方法,来生成摄像头,并且声明一个PerspectiveCamera类型的数组,来存储这些摄像头。该数组需要在camera变量上方声明,要不然就会出现camerasundefined的情况。

let cameras: Array<THREE.PerspectiveCamera> = [];
let camera = createCamera();
// ...

function createCamera() {
  let c = new THREE.PerspectiveCamera(
    45,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );
  cameras.push(c);
  return c;
}

创建炮台摄像头

function createDomeTrackingCamera() {
  let DomeCamera = createCamera();
  DomeCamera.position.set(0, 1, 3);
  DomeCamera.rotation.y = Math.PI;
  return DomeCamera;
}
// main 方法
scene.add(
    createTank(
      createWheels(),
      createDome(createBarrel(createDomeTrackingCamera()))
    )
    // ...
)

创建坦克摄像头

function createTankCamera() {
  let tankCamera = createCamera();
  tankCamera.position.set(-15, 8, 0);
  tankCamera.lookAt(0, 0, 0);
  return tankCamera;
}
// main 方法
scene.add(
    createTank(
        createWheels(),
        createDome(createBarrel(createDomeTrackingCamera())),
        createTankCamera()
    ),
    // ...
)

我们修改render方法来让这三个摄像头切换观看。

time为毫秒数,1000毫秒等于1秒,那么time / 2000就是2秒会切换一个摄像头。可以调试输出下看一下

function render(time) {
  // ...
  let currentCamera = cameras[Math.floor((time / 2000) % cameras.length)];
  if (ResetSize()) {
    const canvas = renderer.domElement;
    cameras.forEach((c) => {
      c.aspect = canvas.clientWidth / canvas.clientHeight;
      c.updateProjectionMatrix();
    });
  }
  // ...
  renderer.render(scene, currentCamera);
}

我们先把currentCamera固定位0号机,我们还有别的东西没画呢,就先不让他切换了。

let currentCamera = cameras[0];

创建目标及目标上的摄像头

// 将这四个元素放外面,待会要用到
let targetOrbit: THREE.Object3D;
let targetObj: THREE.Object3D;
let targetMesh: THREE.Mesh;
let targetCamera: THREE.PerspectiveCamera;

function createTarget() {
  // 该元素以场景的0,0,0为中心
  targetOrbit = new THREE.Object3D();
  // 该元素以 targetOrbit 的坐标再偏-20X,8Y为中心
  targetObj = new THREE.Object3D();
  targetObj.position.set(-20, 8, 0);
  let target = new THREE.SphereGeometry(1, 6, 3);
  let TargetMater = new THREE.MeshPhongMaterial({
    color: "#EE6B3C",
    wireframe,
  });
  targetMesh = new THREE.Mesh(target, TargetMater);
  targetCamera = createCamera();
  targetObj.add(targetCamera);
  targetObj.add(targetMesh);
  // 让相机看往场景中心
  targetCamera.lookAt(0, 0, 0);
  // 将相机定位到目标的后面偏上位置
  targetCamera.position.set(-1.5, 2, 0);
  targetOrbit.add(targetObj);
  return targetOrbit;
}

以下内容对render方法进行修改

让目标围绕坦克旋转起来

targetOrbit.rotation.y += 0.02;

让目标上下左右动并且旋转起来

targetMesh.rotation.x += 0.1;
targetMesh.rotation.y += 0.1;
targetObj.position.y = Math.sin(time * 0.002) * 4 + 10;
targetObj.position.x = Math.sin(time * 0.002) * 4 - 20;

Math.sin方法是计算直角三角形中正弦比值的方法,传入一个数字他会返回一个-1到1之间的数字,比如返回-0.5,那么-0.5*4 = -2,也就是说这个值最大是4最小是-4,再加上/减去一定的数值,那么就固定了这个目标最大可以动到哪里,最小可以动到哪里,让他在一定范围内移动。

让炮台跟随目标移动

先在render方法外层声明targetPosition变量,必须初始化为0,0,0,否则将会出现无法设置X/Y/Z坐标的问题。

let targetPosition: THREE.Vector3 = new THREE.Vector3(0, 0, 0);
// render方法内部
// 先调用getWorldPosition方法获取到targetMesh对于整个场景中的坐标
targetMesh.getWorldPosition(targetPosition);
// 然后让炮筒看向他
BarrelObj.lookAt(targetPosition);