Three.js 进阶之旅:新春特典-Rabbit craft go 🐇

10,747 阅读8分钟

我正在参加「兔了个兔」创意投稿大赛,详情请看:「兔了个兔」创意投稿大赛

声明:本文涉及图文和模型素材仅用于个人学习、研究和欣赏,请勿二次修改、非法传播、转载、出版、商用、及进行其他获利行为。

摘要

兔年到了,祝大家身体健康,万事顺利。本文内容作为兔年新春纪念页面,将使用 Three.js 及 其他前端开发知识,创建一个以兔子为主题的 3D 简单的趣味页面 Rabbit craft go。本文内容包括使用纯代码创建三维浮岛、小河、树木、兔子、胡萝卜以及兔子的运动交互、浮岛的动画效果等。本文包含的知识点相对比较简单,主要包括 使用 Three.js 网格立方体搭建三维卡通场景、键盘事件的监听与三维场景动画的结合等,如果仔细阅读并实践过本专栏《Three.js 进阶之旅》的话,非常容易掌握。

background.png

🚩 兔子造型来源于 Three.js开源论坛,页面整体造型灵感来源于《我的世界》,页面名称灵感来源于游戏《Lara Craft Go》。

效果

我们先来看看实现效果,页面加载完成后是一个游戏操作提示界面,可以通过键盘 空格键WASD 或方向键操作小兔子运动。点击开始按钮后,游戏提示界面消失,可以看到 掘金Logo 造型的天空浮岛及浮岛上方的树木 🌳、河流 、桥 🌉、胡萝卜 🥕、兔子 🐇 等元素,接着摄像机镜头 📹 自动拉近并聚焦到兔子上。

preview.gif

按照操作提示界面的按键,可以操作兔子进行前进、转向、跳跃等运动,当兔子的运动位置触碰到胡萝卜时,胡萝卜会消失同时兔子会进行跳跃运动。当兔子运动到小河或者超出浮岛范围时,兔子则会坠落到下方。

animate.gif

打开以下链接,在线预览效果,大屏访问效果更佳。

本专栏系列代码托管在 Github 仓库【threejs-odessey】后续所有目录也都将在此仓库中更新

🔗 代码仓库地址:git@github.com:dragonir/threejs-ode…

码上掘金

实现

文章篇幅有限,因此删减了三维模型的位置信息等细节调整代码,只提供构建三维模型的整体思路逻辑,想了解该部分内容的详细介绍可以阅读本专栏前几篇文章及阅读本文配套源码。现在,我们来看看整个页面的实现详细步骤:

页面结构

Rabbit Craft Go 页面的整体结构如下,其中 canvas.webgl 是用于渲染场景的容器、剩余标签都是一些装饰元素或提示语。

<canvas class="webgl"></canvas>
<div class="mask" id="mask">
  <div class="box">
    <div class="keyboard">
      <div class="row"><span class="key">W/↑</span></div>
      <div class="row"><span class="key">A/←</span><span class="key">S/↓</span><span class="key">D/→</span></div>
      <div class="row"><span class="key space">space</span></div>
    </div>
    <p class="tips"><b>W</b>: 行走&emsp;<b>S</b>: 停止&emsp;<b>A</b>: 向左转&emsp;<b>D</b>: 向右转&emsp;<b>空格键</b>: 跳跃</p>
    <p class="start"><button class="button" id="start_button">开始</button></p>
  </div>
</div>
<a class='github' href='https://github.com/dragonir/threejs-odessey' target='_blank' rel='noreferrer'>
  <span class='author'>three.js odessey</span>
</a>
<h1 class="title">RABBIT CRAFT GO!</h1>
<div class="banner"><i></i></div>

场景初始化

场景初始化过程中,我们引入必需的开发资源,并初始化渲染场景、相机、控制器、光照、页面缩放适配等。其中外部资源的引入,其中 OrbitControls 用于页面镜头缩放及移动控制;TWEENAnimations 用于生成镜头补间动画,也就是刚开始时浮岛由远及近的镜头切换动画中效果;IslandCarrotRabbitWaterfall 等是用来构建三维世界的类。为了使场景更加卡通化,使用了 THREE.sRGBEncoding 渲染效果。场景中添加了两种光源,其中 THREE.DirectionalLight 用来生成阴影效果。

import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { TWEEN } from "three/examples/jsm/libs/tween.module.min.js";
import Animations from './environment/animation';
import Island from './environment/island';
import Carrot from './environment/carrot';
import Rabbit from './environment/rabbit';
import Waterfall from './environment/waterfall';

// 初始化渲染器
const canvas = document.querySelector('canvas.webgl');
const renderer = new THREE.WebGLRenderer({
  canvas: canvas,
  antialias: true,
  alpha: true
});
renderer.setSize(sizes.width, sizes.height);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputEncoding = THREE.sRGBEncoding;
renderer.shadowMap.enabled = true;
renderer.shadowMap.needsUpdate = true;

// 初始化场景
const scene = new THREE.Scene();

// 初始化相机
const camera = new THREE.PerspectiveCamera(60, sizes.width / sizes.height, 1, 5000)
camera.position.set(-2000, -250, 2000);

// 镜头控制器
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0, 0);
controls.enableDamping = true;
controls.enablePan = false;
controls.dampingFactor = 0.15;

// 页面缩放事件监听
window.addEventListener('resize', () => {
  sizes.width = window.innerWidth;
  sizes.height = window.innerHeight;
  // 更新渲染
  renderer.setSize(sizes.width, sizes.height);
  renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
  // 更新相机
  camera.aspect = sizes.width / sizes.height;
  camera.updateProjectionMatrix();
});

// 光照
const ambientLight = new THREE.AmbientLight(0xffffff, 1);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.5);
scene.add(directionalLight);

step_0.png

创建浮岛

如以下 👇 两幅图所示,整个浮岛造型是一个四棱椎,整体分为四部分,顶部是由地面和河流构成的四方体、底部三块是倒置的 掘金Logo。生成这些三维模型的其实也并没有多少技巧,就像搭积木一样使用 Three.js 提供的立方体网格通过计算拼接到一起即可。类 Island 包含一个方法 generate 用于创建上述三维模型,并将所创建模型添加到三维分组 floorMesh 中用于外部调用,其中棱柱部分是通过 CylinderBufferGeometry 来实现的。

export default class Island {
  constructor() {
    this.floorMesh = new THREE.Group();
    this.generate();
  }

  generate() {
    // 左侧地面
    const leftFieldMat = new THREE.MeshToonMaterial({
      color: 0x015521d,
      side: THREE.DoubleSide,
    });
    const leftFieldGeom = new THREE.BoxBufferGeometry(800, 30, 1800);
    this.leftFieldMesh = new THREE.Mesh(leftFieldGeom, leftFieldMat);
    // 右侧地面
    this.rightFieldMesh = this.leftFieldMesh.clone();
    const mapCapMat = new THREE.MeshMatcapMaterial({
      matcap: new THREE.TextureLoader().load('./images/matcap.png'),
      side: THREE.DoubleSide
    })
    // 顶部棱柱
    const topFieldGeom = new THREE.CylinderBufferGeometry(1200, 900, 200, 4, 4);
    this.topFieldMesh = new THREE.Mesh(topFieldGeom, mapCapMat);
    // 中间棱柱
    const middleFieldGeom = new THREE.CylinderBufferGeometry(850, 600, 200, 4, 4);
    this.middleFieldMesh = new THREE.Mesh(middleFieldGeom, mapCapMat);
    // 底部棱锥
    const bottomFieldGeom = new THREE.ConeBufferGeometry(550, 400, 4);
    this.bottomFieldMesh = new THREE.Mesh(bottomFieldGeom, mapCapMat);
    // 河面
    const strGroundMat = new THREE.MeshLambertMaterial({
      color: 0x75bd2d,
      side: THREE.DoubleSide,
    });
    const strCroundGeom = new THREE.BoxBufferGeometry(205, 10, 1800);
    this.strGroundMesh = new THREE.Mesh(strCroundGeom, strGroundMat);

    // 小河
    const streamMat = new THREE.MeshLambertMaterial({
      color: 0x0941ba,
      side: THREE.DoubleSide,
    });
    const streamGeom = new THREE.BoxBufferGeometry(200, 16, 1800);
    this.streamMesh = new THREE.Mesh(streamGeom, streamMat);
    // ...
  }
};

浮岛俯视图是一个正方形

step_1.png

浮岛侧视图是一个倒三角形

step_2.png

创建水流

接下来,我们为河流添加一个小瀑布,使场景动起来。流动的瀑布三维水滴 💧 滴落效果的是通过创建多个限定范围内随机位置的 THREE.BoxBufferGeometry 来实现水滴模型,然后通过水滴的显示隐藏动画实现视觉上的水滴坠落效果。Waterfall 类用于创建单个水滴,它为水滴初始化随机位置和速度,并提供一个 update 方法用来更新它们。

export default class Waterfall {
  constructor (scene) {
    this.scene = scene;
    this.drop = null;
    this.generate();
  }
  generate () {
    this.geometry = new THREE.BoxBufferGeometry(15, 50, 5);
    this.material = new THREE.MeshLambertMaterial({ color: 0x0941ba });
    this.drop = new THREE.Mesh(this.geometry, this.material);
    this.drop.position.set((Math.random() - 0.5) * 200, -50, 900 + Math.random(1, 50) * 10);
    this.scene.add(this.drop);
    this.speed = 0;
    this.lifespan = Math.random() * 50 + 50;
    this.update = function() {
      this.speed += 0.07;
      this.lifespan--;
      this.drop.position.x += (5 - this.drop.position.x) / 70;
      this.drop.position.y -= this.speed;
    };
  }
};

完成水滴创建后,不要忘了需要在页面重绘动画 tick 方法中像这样更新已创建的水滴数组 drops,使其看起来生成向下流动坠落的效果。

for (var i = 0; i < drops.length; i++) {
  drops[i].update();
  if (drops[i].lifespan < 0) {
    scene.remove(scene.getObjectById(drops[i].drop.id));
    drops.splice(i, 1);
  }
}

step_3.gif

创建桥

在河流上方添加一个小木桥 🌉,这样小兔子就可以通过木桥在小河两边移动了。 类 Bridge 通过 generate 方法创建一个小木桥,并通过三维模型组 bridgeMesh 将其导出,我们可以在上面创建的 Island 类中使用它,将其添加到三维场景中。

export default class Bridge {
  constructor() {
    this.bridgeMesh = new THREE.Group();
    this.generate();
  }
  generate() {
    var woodMat = new THREE.MeshLambertMaterial({
      color: 0x543b14,
      side: THREE.DoubleSide
    });
    // 木头
    for (var i = 0; i < 15; i++) {
      var blockGeom = new THREE.BoxBufferGeometry(10, 3, 70);
      var block = new THREE.Mesh(blockGeom, woodMat);
      this.bridgeMesh.add(block);
    }
    // 桥尾
    var geometry_rail_v = new THREE.BoxBufferGeometry(3, 20, 3);
    var rail_1 = new THREE.Mesh(geometry_rail_v, woodMat);
    var rail_2 = new THREE.Mesh(geometry_rail_v, woodMat);
    var rail_3 = new THREE.Mesh(geometry_rail_v, woodMat);
    var rail_4 = new THREE.Mesh(geometry_rail_v, woodMat);
    // ...
  }
}

step_4.png

创建树

从预览动图和页面可以看到,浮岛上共有两种树 🌳,绿色的高树和粉红色的矮树,树的实现也非常简单,是使用了两个 BoxBufferGeometry 拼接到一起。类 TreeLeafTree 分别用于生成这两种树木,接收参数 (x, y, z) 分别表示树木在场景中的位置信息。我们可以在 Island 辅导上添加一些树木,构成浮岛上的一片小森林。

export default class Tree {
  constructor(x, y, z) {
    this.x = x;
    this.y = y;
    this.z = z;
    this.treeMesh = new THREE.Group();
    this.generate();
  }
  generate() {
    // 树干
    var trunkMat = new THREE.MeshLambertMaterial({
      color: 0x543b14,
      side: THREE.DoubleSide
    });
    var trunkGeom = new THREE.BoxBufferGeometry(20, 200, 20);
    this.trunkMesh = new THREE.Mesh(trunkGeom, trunkMat);
    // 树叶
    var leavesMat = new THREE.MeshLambertMaterial({
      color: 0x016316,
      side: THREE.DoubleSide
    });
    var leavesGeom = new THREE.BoxBufferGeometry(80, 400, 80);
    this.leavesMesh = new THREE.Mesh(leavesGeom, leavesMat);
    this.treeMesh.add(this.trunkMesh);
    this.treeMesh.add(this.leavesMesh);
    this.treeMesh.position.set(this.x, this.y, this.z);
    // ...
  }
}

矮树

export default class LeafTree {
  constructor(x, y, z) {
    this.x = x;
    this.y = y;
    this.z = z;
    this.treeMesh = new THREE.Group();
    this.generate();
  }
  generate() {
    // ...
  }
}

step_5.png

创建胡萝卜

接着,在地面上添加一些胡萝卜 🥕。胡萝卜身体部分是通过四棱柱 CylinderBufferGeometry 实现的,然后通过 BoxBufferGeometry 立方体来实现胡萝卜的两片叶子。场景中可以通过 Carrot 类来添加胡萝卜,本页面示例中是通过循环调用添加了 20 个随机位置的胡萝卜。

export default class Carrot {
  constructor() {
    this.carrotMesh = new THREE.Group();
    this.generate();
  }
  generate() {
    const carrotMat = new THREE.MeshLambertMaterial({
      color: 0xd9721e
    });
    const leafMat = new THREE.MeshLambertMaterial({
      color: 0x339e33
    });
    // 身体
    const bodyGeom = new THREE.CylinderBufferGeometry(5, 3, 12, 4, 1);
    this.body = new THREE.Mesh(bodyGeom, carrotMat);
    // 叶子
    const leafGeom = new THREE.BoxBufferGeometry(5, 10, 1, 1);
    this.leaf1 = new THREE.Mesh(leafGeom, leafMat);
    this.leaf2 = this.leaf1.clone();
    // ...
    this.carrotMesh.add(this.body);
    this.carrotMesh.add(this.leaf1);
    this.carrotMesh.add(this.leaf2);
  }
};
for (let i = 0; i < 20; i++) {
  carrot[i] = new Carrot();
  scene.add(carrot[i].carrotMesh);
  carrot[i].carrotMesh.position.set(-170 * Math.random() * 3 - 300, -12, 1400 * Math.random() * 1.2 - 900);
}

step_6.png

创建兔子

最后,来创建页面的主角兔子 🐇。兔子全部都是由立方体 BoxBufferGeometry 搭建而成的,整体可以分解为头、眼睛、耳朵、鼻子、嘴、胡须、身体、尾巴、四肢等构成,构建兔子时的核心要素就是各个立方体位置和缩放比例的调整,需要具备一定的审美能力,当然本例中使用的兔子是在 Three.js 社区开源代码的基础上改造的 😂

完成兔子的整体外形之后,我们通过 gsap 给兔子添加一些运动动画效果和方法以供外部调用,其中 blink() 方法用于眨眼、jump() 方法用于原地跳跃、nod() 方法用于点头、run() 方法用于奔跑、fall() 方法用于边界检测时检测到超出运动范围时使兔子坠落效果等。完成 Rabbit 类后,我们就可以在场景中初始化小兔子。

import { TweenMax, Power0, Power1, Power4, Elastic, Back } from 'gsap';

export default class Rabbit {
  constructor() {
    this.bodyInitPositions = [];
    this.runningCycle = 0;
    this.rabbitMesh = new THREE.Group();
    this.bodyMesh = new THREE.Group();
    this.headMesh = new THREE.Group();
    this.generate();
  }
  generate() {
    var bodyMat = new THREE.MeshLambertMaterial({
      color: 0x5c6363
    });
    var tailMat = new THREE.MeshLambertMaterial({
      color: 0xc2bebe
    });
    var nouseMat = new THREE.MeshLambertMaterial({
      color: 0xed716d
    });
    // ...
    var pawMat = new THREE.MeshLambertMaterial({
      color: 0xbf6970
    });
    var bodyGeom = new THREE.BoxBufferGeometry(50, 50, 42, 1);
    var headGeom = new THREE.BoxBufferGeometry(44, 44, 54, 1);
    var earGeom = new THREE.BoxBufferGeometry(5, 60, 10, 1);
    var eyeGeom = new THREE.BoxBufferGeometry(20, 20, 8, 1);
    var irisGeom = new THREE.BoxBufferGeometry(8, 8, 8, 1);
    var mouthGeom = new THREE.BoxBufferGeometry(8, 16, 4, 1);
    var mustacheGeom = new THREE.BoxBufferGeometry(0.5, 1, 22, 1);
    var spotGeom = new THREE.BoxBufferGeometry(1, 1, 1, 1);
    var legGeom = new THREE.BoxBufferGeometry(33, 33, 10, 1);
    var pawGeom = new THREE.BoxBufferGeometry(45, 10, 10, 1);
    var pawFGeom = new THREE.BoxBufferGeometry(20, 20, 20, 1);
    var tailGeom = new THREE.BoxBufferGeometry(20, 20, 20, 1);
    var nouseGeom = new THREE.BoxBufferGeometry(20, 20, 15, 1);
    var tailGeom = new THREE.BoxBufferGeometry(23, 23, 23, 1);
    this.body = new THREE.Mesh(bodyGeom, bodyMat);
    this.bodyMesh.add(this.body);
    this.head = new THREE.Mesh(headGeom, bodyMat);
    this.bodyMesh.add(this.legL);
    this.headMesh.add(this.earR);
    this.rabbitMesh.add(this.bodyMesh);
    this.rabbitMesh.add(this.headMesh);
    // ...
  }
  blink() {
    var sp = 0.5 + Math.random();
    if (Math.random() > 0.2)
      TweenMax.to([this.eyeR.scale, this.eyeL.scale], sp / 8, {
        y: 0,
        ease: Power1.easeInOut,
        yoyo: true,
        repeat: 3
      });
  }
  // 跳跃
  jump() {
    var speed = 10;
    var totalSpeed = 10 / speed;
    var jumpHeight = 150;
    TweenMax.to(this.earL.rotation, totalSpeed / 2, {
      z: "+=.3",
      ease: Back.easeOut,
      yoyo: true,
      repeat: 1
    });
    TweenMax.to(this.earR.rotation, totalSpeed / 2, {
      z: "-=.3",
      ease: Back.easeOut,
      yoyo: true,
      repeat: 1
    });
    // ...
  }
  // 点头
  nod() {}
  // 奔跑
  run() {}
  // 移动
  move() {}
  // 坠落
  fall() {}
  // 动作销毁
  killNod() {}
  killJump() {}
  killMove() {}
}

step_7.png

将兔子添加到场景中。

step_8.png

添加动画和操作

为了使兔子可以运动和可交互,我们通过监听键盘按键的方式来调用兔子类内置的对应动画方法,兔子的方向转动可以通过修改兔子的旋转属性 rotation 来实现。

// 兔子控制
const rabbitControl = {
  tureLeft: () => {
    rabbit && (rabbit.rabbitMesh.rotation.y -= Math.PI / 2);
  },
  turnRight: () => {
    rabbit && (rabbit.rabbitMesh.rotation.y += Math.PI / 2);
  },
  stopMove: () => {
    rabbitMoving = false;
    rabbit.killMove();
    rabbit.nod();
  },
}

// 键盘监听
document.addEventListener('keydown', e => {
  if (e && e.keyCode) {
    switch(e.keyCode) {
      // 左
      case 65:
      case 37:
        rabbitControl.tureLeft();
        break;
      // 右
      case 68:
      case 39:
        rabbitControl.turnRight();
        break;
      // 前
      case 87:
      case 38:
        rabbitMoving = true;
        break;
      // 空格键
      case 32:
        !rabbitJumping && rabbit.jump() && (rabbitJumping = true);
        break;
      default:
        break;
    }
  }
});

document.addEventListener('keyup', e => {
  if (e && e.keyCode) {
    switch(e.keyCode) {
      case 83:
      case 40:
      case 87:
      case 38:
        rabbitMoving = false;
        rabbit.killMove();
        rabbit.nod();
        break;
      case 32:
        setTimeout(() => {
          rabbitJumping = false;
        }, 800);
        break;
    }
  }
});

为了使场景更加真实和趣味,我们可以添加一些边界检测方法,当兔子位置处于非可运动区域如小河、浮岛之外等区域时,可以调用兔子的 fall(),方法使其坠落。当检测到兔子的位置和胡萝卜的位置重叠时,给兔子添加了一个 jump() 跳跃动作并使检测到的这个胡萝卜从场景中移除。

const checkCollision = () => {
  for (let i = 0; i < 20; i++) {
    let rabbCarr = rabbit.rabbitMesh.position.clone().sub(carrot[i].carrotMesh.position.clone());
    if (rabbCarr.length() <= 20) {
      rabbit.jump();
      scene.remove(carrot[i].carrotMesh);
      rabbCarr = null;
    }
  }
  // 检查是否是地面的边界
  var rabbFloor = island.floorMesh.position.clone().sub(rabbit.rabbitMesh.position.clone());
  if (
    rabbFloor.x <= -900 ||
    rabbFloor.x >= 900 ||
    rabbFloor.z <= -900 ||
    rabbFloor.z >= 900
  ) {
    rabbit.fall();
  }
  // 小河检测
  var rabbStream = rabbit.rabbitMesh.position.clone().sub(island.streamMesh.position.clone());
  if (
    (rabbStream.x >= -97 &&
      rabbStream.x <= 97 &&
      rabbStream.z >= -900 &&
      rabbStream.z <= 688) ||
    (rabbStream.x >= -97 && rabbStream.x <= 97 && rabbStream.z >= 712)
  ) {
    rabbit.fall();
  }
}

animate_1.gif

页面装饰

最后,我们来制作一个其实页面,中间部分是键盘操作说明,底部是一些装饰文案图片,操作提示下方是一个开始按钮,我们给这个按钮添加一个通过 TWEEN.js 实现的镜头补间动画效果,当点击按钮时,页面首先显示的是倒置 掘金Logo 造型的浮岛,然后镜头慢慢方法拉近,显示出兔子运动的区域。本页面为了使其看起来更加符合游戏主题,标题文案使用了一种像素化的字体

const startButton = document.getElementById('start_button');
const mask = document.getElementById('mask');
startButton.addEventListener('click', () => {
  mask.style.display = 'none';
  Animations.animateCamera(camera, controls, { x: 50, y: 120, z: 1000 }, { x: 0, y: 0, z: 0 }, 3600, () => {});
});

step_9.png

🔗 源码地址:github.com/dragonir/th…

总结

本文中主要包含的知识点包括:

  • 使用 Three.js 网格立方体搭建三维卡通场景
  • 键盘事件的监听与三维场景动画的结合

想了解其他前端知识或其他未在本文中详细描述的Web 3D开发技术相关知识,可阅读我往期的文章。如果有疑问可以在评论中留言,如果觉得文章对你有帮助,不要忘了一键三连哦 👍

附录

参考