【转载】【译】基于 Three.js 实现可交互式 3D 人物

本文转载自 【译】基于 Three.js 实现可交互式 3D 人物 · Issue #45 · JChehe/blog

作者:@JChehe

原文:How to Create an Interactive 3D Character with Three.js

在本长篇教程中,你将学会如何创建一个头部朝向鼠标和点击执行随机动画的交互式 3D 模型。

封面

你是否曾经拥有一个展示职业生涯的个人网站,并且里面放着一张个人照片?最近我想更进一步,往里面添加一个完全交互式 3D 版本的自己,它能注视用户的光标。当然这还不够,你甚至可以点击“我”,然后我会作出动作进行响应。本篇教程将讲述如何基于名为 Stacy 的模型实现这件事。

以下就是体验案例(点击 Stacy,同时移动鼠标观察它的动作)。

由于是基于 Three.js 实现,我假设你已掌握了 JavaScript。

See the Pen Character Tutorial - Final by Kyle Wetton (@kylewetton) on CodePen.

模型 带有 10 个动画。而在本教程的最后一节,我将会阐述如何为模型添加多个动画。简言而之,模型是基于 Blender,动画是来自 Adobe 的免费动画网站——Mixamo

Part 1:初始化项目 HTML、CSS

以下这个 pen(译者注:CodePen 的一个实例)包含了项目所有的 HTML 和 CSS。你可以 Fork 这个 pen 或从这里复制 HTML 和 CSS 到一个新项目。

See the Pen Character Tutorial - Blank by Kyle Wetton (@kylewetton) on CodePen.

HTML 含有一个加载动画(目前已注释,需要时再恢复)、一个包装(wrapper)div 和最重要的 canvas 标签。该 canvas 是 Three.js 拿来渲染场景的,另外 CSS 将其设为视口 100% 宽高大小。在 HTML 底部加载了两个依赖:Three.js 和 GLTFLoader(GLTF 是本教程引用的 3D 模型格式)。当然,这两个依赖都可作为 npm 模块使用。

CSS 含有一小部分“居中”样式,其余是 loading 动画。现在,你可以折叠 HTML 和 CSS 代码,我会在需要的时候再深入讲解。

Part 2:构建场景(Scene)

上一篇教程(译文:《【译】基于 Three.js 实现 3D 模型换肤》),我的做法是在用到全局变量时再回到文件顶部添加。而这次,我要把所有这些都预先定义,在需要时再讲解它们的作用。当然,每行都带有注释以满足你的好奇心。将这些全局变量放在一个函数内:

(function() {
// Set our main variables
let scene,  
  renderer,
  camera,
  model,                              // Our character
  neck,                               // Reference to the neck bone in the skeleton
  waist,                               // Reference to the waist bone in the skeleton
  possibleAnims,                      // Animations found in our file
  mixer,                              // THREE.js animations mixer
  idle,                               // Idle, the default state our character returns to
  clock = new THREE.Clock(),          // Used for anims, which run to a clock instead of frame rate 
  currentlyAnimating = false,         // Used to check whether characters neck is being used in another anim
  raycaster = new THREE.Raycaster(),  // Used to detect the click on our character
  loaderAnim = document.getElementById('js-loader');

})(); // Don't add anything below this line

初始化 Three.js 的工作包含场景(scene)、渲染器(renderer)、摄像机(camera)、光(lights)和一个更新函数(每帧执行)。

以上这些工作都在 init() 函数内完成。在声明变量后(仍在函数作用域内)添加该初始化函数:

init(); 

function init() {

}

在初始化函数内,先引用 canvas 元素和声明背景色(淡灰色)。需要注意的是,Three.js 不能使用字符串格式的颜色值,如 '#f1f1f1',而使用十六机制的整数,如 0xf1f1f1

const canvas = document.querySelector('#c');
const backgroundColor = 0xf1f1f1;

接着,创建场景,并设置背景色和添加雾化效果。但在本教程中,你并不能看出有雾化效果,因为地板和背景色是一致的。若两者不一致,则能明显看到雾化的模糊效果。

// Init the scene
scene = new THREE.Scene();
scene.background = new THREE.Color(backgroundColor);
scene.fog = new THREE.Fog(backgroundColor, 60, 100);

接着是渲染器(renderer),向渲染器的构造函数传入 canvas 引用和其它可选项。这里唯一一个可选项是启用抗齿距。另外,启用了 shadowMap,使得人物对象能投射阴影;基于设备设置了像素比,使得移动端的渲染效果更清晰,否则 canvas 会在高分度屏幕上呈现像素化。最后,将渲染器添加到 document.body(译者注:此行代码可省略)。

// Init the renderer
renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.shadowMap.enabled = true;
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);

这就完成了 Three.js 初始化工作的前两个。接下来是摄像机(camera)。创建一个透视摄像机,并设置其视场(field of view, fov)为 50,横纵向比例为视口宽高比,默认的前后边界裁剪区域。然后,将其往后 30 个单位和往下 3 个单位位移。后续你会明白为何这么做。这些参数都可以尝试更改,但建议目前就使用这些参数。

// Add a camera
camera = new THREE.PerspectiveCamera(
  50,
  window.innerWidth / window.innerHeight,
  0.1,
  1000
);
camera.position.z = 30 
camera.position.x = 0;
camera.position.y = -3;

scene、renderer 和 camera 变量均已在项目顶部声明。

缺少光,摄像机就不能看到任何东西。那就现在创建两个光——环境光和定向光。然后,通过 scene.add(light) 将它们加到场景中。

将光相关的代码放在摄像机下方,后面我会解释这具体做了什么:

// Add lights
let hemiLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 0.61);
hemiLight.position.set(0, 50, 0);
// Add hemisphere light to scene
scene.add(hemiLight);

let d = 8.25;
let dirLight = new THREE.DirectionalLight(0xffffff, 0.54);
dirLight.position.set(-8, 12, 8);
dirLight.castShadow = true;
dirLight.shadow.mapSize = new THREE.Vector2(1024, 1024);
dirLight.shadow.camera.near = 0.1;
dirLight.shadow.camera.far = 1500;
dirLight.shadow.camera.left = d * -1;
dirLight.shadow.camera.right = d;
dirLight.shadow.camera.top = d;
dirLight.shadow.camera.bottom = d * -1;
// Add directional Light to scene
scene.add(dirLight);

环境光为强度 0.61 的白光,然后将其放置在中心点上方 50 单位。你也可以在后续尝试更改数值。

我根据个人感觉将定向光放置在一个适当的位置。随后,启用其投射阴影的能力并设置了阴影的分辨率。阴影的其余设置则与光的视场相关(译者注:定向光是使用正交摄像机计算阴影,参考 DirectionalLightShadow),这概念对我来说也有些模糊,但只要清晰知道:可通过调整变量 d 以确保阴影不被裁剪。

与此同时,在 init 函数内添加地板:

// Floor
let floorGeometry = new THREE.PlaneGeometry(5000, 5000, 1, 1);
let floorMaterial = new THREE.MeshPhongMaterial({
  color: 0xeeeeee,
  shininess: 0,
});

let floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -0.5 * Math.PI; // This is 90 degrees by the way
floor.receiveShadow = true;
floor.position.y = -11;
scene.add(floor);

首先,创建一个二维平面,它足够大:5000 个单位(确保无缝背景)。

然后创建一个材质(整篇教程中,我们只创建了两种不同的材质),并将它与几何图形结合为网格(mesh),最后将该网格添加到场景中。该网格足够大,被平放作为地面。网格的颜色是 0xeeeeee,虽然比背景色稍暗,但在灯光的作用下,与不受灯光影响的背景融为一体。

地板是由几何图形和材质结合而成的网格。通读一下我们刚添加的代码,我想你会发现一切都是不言自明。为了配合后续添加的人物模型,我们将地板向下移动 11 个单位。

这就是 init() 函数目前的内容。

Three.js 应用一般都会依赖于一个每帧都会执行的更新函数,如果你有涉猎过 Unity,那么它与游戏引擎的工作方式类似。该函数需要放在 init() 函数后,而不是其内部。在更新函数内,renderer 会渲染摄像机下的场景,并立刻再次调用自身。

function update() {
  renderer.render(scene, camera);
  requestAnimationFrame(update);
}
update();

场景由此正式打开。canvas 目前看到的是亮灰色,实际是背景和地板。你可以更改地板的材质颜色为 0xff0000 进行测试,但记得改回来哦。

我们将在下一节加载模型。在此之前,还需要为场景做一件事。canvas 作为一个 HMTL 元素,其 CSS 属性 width 和 height 均被设为 100%,这使得它能基于其容器良好地适配尺寸大小。但场景也需要同步调整大小以保持比例。因此,在调用 update 函数下方(非其定义内部)添加这个功能。其所做的事情是:不断检查 renderer 的尺寸是否与 canvas 相等,若不等则设置 renderer 的尺寸,最后返回布尔值变量 needResize(译者注:建议通过监听 window resize 事件处理)。

function resizeRendererToDisplaySize(renderer) {
  const canvas = renderer.domElement;
  let width = window.innerWidth;
  let height = window.innerHeight;
  let canvasPixelWidth = canvas.width / window.devicePixelRatio;
  let canvasPixelHeight = canvas.height / window.devicePixelRatio;

  const needResize =
    canvasPixelWidth !== width || canvasPixelHeight !== height;
  if (needResize) {
    renderer.setSize(width, height, false);
  }
  return needResize;
}

在 update 函数内找到这几行代码:

renderer.render(scene, camera);
requestAnimationFrame(update);

在这几行代码的上方,我们会调用该函数以检查是否需要调整大小,并在需要时更新摄像机的横纵向比例以适应新尺寸。

if (resizeRendererToDisplaySize(renderer)) {
  const canvas = renderer.domElement;
  camera.aspect = canvas.clientWidth / canvas.clientHeight;
  camera.updateProjectionMatrix();
}

完整的 update 函数如下:

function update() {

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

update();

function resizeRendererToDisplaySize(renderer) { ... }

至此,我们整个项目如下。下一节是加载模型。

See the Pen Character Tutorial - Round 1 by Kyle Wetton (@kylewetton) on CodePen.

Part 3:添加模型

尽管场景目前十分空旷,但该有的配置都准备好了,如自适应大小、光和摄像机。现在就开始添加模型吧。

在 init() 函数顶部的 canvas 变量前引用模型。这是 GLTF 格式(.glb),尽管 Three.js 支持多种 3D 模型格式,但这是推荐的格式。我们将使用 GLTFLoader 加载模型。

const MODEL_PATH = 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy_lightweight.glb';

在 init() 函数的 camera 下方,创建一个 loader:

var loader = new THREE.GLTFLoader();

然后使用该 loader 的 load 方法,它接受 4 个参数,分别是:模型路径、模型加载成功后的回调函数、模型加载中的回调函数、报错的回调函数。

var loader = new THREE.GLTFLoader();

loader.load(
  MODEL_PATH,
  function(gltf) {
   // A lot is going to happen here
  },
  undefined, // We don't need this function
  function(error) {
    console.error(error);
  }
);

请注意注释“A lot is going to happen here”,这里是模型加载后会执行的地方。除非特别声明,否则接下来所有东西都放在该函数内。

GLTF 文件本身(即传入该回调函数的形参 gltf)由两部分组成,场景(gltf.scene,【译者注:即模型】)和动画(gltf.animations)。在该函数顶部引用它们,并将该模型添加到场景中:

model = gltf.scene;
let fileAnimations = gltf.animations;

scene.add(model);

至此,完整的 loader.load 函数如下:

loader.load(
  MODEL_PATH,
  function(gltf) {
    // A lot is going to happen here
    model = gltf.scene;
    let fileAnimations = gltf.animations;

    scene.add(model);
    
  },
  undefined, // We don't need this function
  function(error) {
    console.error(error);
  }
);

注意:model 变量早已在项目顶部声明。

现在你会看到场景中有一个小人物。

a small figure

有几件事需要说明:

  • 模型很小;3D 模型如同矢量图形,支持不失真缩放;Mixamo 输出的模型很小,因此,我们需要对它进行放大。(译者注:可尝试调整摄像机的距离)
  • GLTF 模型支持包含纹理,但我不这样做的原因有几点:1. 解耦可以拥有更小的文件大小;2. 关于色彩空间,对于这点我会在本教程的最后一节——如何构建 3D 模型中详细讨论。

将模型添加到场景前,我们需要做几件事。

首先,使用模型的 traverse 方法遍历所有网格(mesh)以启用投射和接收阴影的能力。该操作需要在 scene.add(model) 前完成。

model.traverse(o ={
  if (o.isMesh) {
    o.castShadow = true;
    o.receiveShadow = true;
  }
});

然后,将模型在原来大小的基础上放大 7 倍。该操作在 traverse 方法下方添加:

// Set the models initial scale
model.scale.set(7, 7, 7);

最后,将模型向下移动 11 个单位,以保证它是站在地板上的。

model.position.y = -11;

model's scale

完美,我们已成功加载模型。接着,我们加载并应用纹理。该模型带有纹理,并在 Blender 中已对模型进行贴图(map)。该过程被称为 UV mapping。你可以下载该图片进行观察,如果你想尝试制作属于自己的模型,可以学习更多关于 UV mapping 的知识。

之前我们已声明 loader 变量;在该声明的上方创建一个新纹理和材质:

let stacy_txt = new THREE.TextureLoader().load('https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy.jpg');

stacy_txt.flipY = false; // we flip the texture so that its the right way up

const stacy_mtl = new THREE.MeshPhongMaterial({
  map: stacy_txt,
  color: 0xffffff,
  skinning: true
});

// We've loaded this earlier
var loader = new THREE.GLTFLoader()

纹理不仅是一张图片的 URL,它要作为一个新纹理,需要通过 TextureLoader 加载。我们将其赋值给 stacy_txt 变量。

在前面,我们已使用过材质。这个颜色为 0xeeeeee 的材质被用于地板。在这里,我们将为模型的材质使用一些新选项:1. 将 stacy_txt 纹理赋值给 map 属性;2. 将 skinning 设置为 true,这对动画模型至关重要。最后将该材质赋值给 stacy_mtl

现在我们有了纹理材质。因为模型(gltf.scene)仅有一个对象,所以我们直接在 traverse 方法的阴影相关代码下方增添一行代码:

model.traverse(o ={
 if (o.isMesh) {
   o.castShadow = true;
   o.receiveShadow = true;
   o.material = stacy_mtl; // Add this line
 }
});

with materials

就这样,模型就成为了一个可辨识的角色——Stacy。

不过她有点死气沉沉,下一节我们将处理动画。现在你已接触过几何体和材质,就让我们用这些所学到的知识让场景变得更有趣。

在地板代码下方(即 init() 函数的最后一行代码),添加一个圆符。这是一个很大但远离我们的 3D 球体,并使用 BasicMaterial 材质。该材质不具备先前使用的 PhongMaterial 材质所拥有的光泽和投射并接收阴影的特性。因此,它在该场景中能作为一个平面圆,很好地衬托着 Stacy。

let geometry = new THREE.SphereGeometry(8, 32, 32);
let material = new THREE.MeshBasicMaterial({ color: 0x9bffaf }); // 0xf2ce2e 
let sphere = new THREE.Mesh(geometry, material);
sphere.position.z = -15;
sphere.position.y = -2.5;
sphere.position.x = -0.25;
scene.add(sphere);

可以改成你喜欢的颜色!

Part 4:赋予 Stacy 生气

在进入本节主题前,你可能注意到 Stacy 的加载需要一段时间。显然,白屏对用户并不友好。我曾提及到:在 HTML 中我们有一个 loading 元素被注释。现在回到那里取消这个注释。

<!-- The loading element overlays everything else until the model is loaded, at which point we remove this element from the DOM -- 
<div class="loading" id="js-loader"><div class="loader"></div></div>

再次回到 loader 函数。

loaderAnim.remove();

一旦将 Stacy 添加至场景,就删除 loading 动画遮罩层。保存更改并刷新浏览器,在看到 Stacy 前会有一个加载动画。若模型已被缓存,则可能会因太快而看不到加载动画。

是时候进入模型动画了!

仍在 loader 函数,我们将创建一个 AnimationMixer,它是用于播放场景中特定对象动画的播放器。它看来有些陌生,也超出本教程的范围。若想了解更多,可阅读 Three.js 文档的 AnimationMixer。而本文并不要求你知道关于它的更多内容。

在删除 loading 动画下方添加这行代码,其中传入的参数是我们的模型:

mixer = new THREE.AnimationMixer(model);

注意 mixer 已在项目顶部声明。

在这行代码下方,我们创建 AnimationClip,并通过 fileAnimations 查找一个名为 idle(空闲)的动画。这个名字是在 Blender 中设置的。

let idleAnim = THREE.AnimationClip.findByName(fileAnimations, 'idle');

然后,使用 mixer 的 clipAction 方法,并传入 idleAnim 参数。我们将这个 clipAction 命名为 idle

最后,调用 idleplay 方法:

idle = mixer.clipAction(idleAnim);
idle.play();

其实这还不能让动画执行起来,我们还需要做一件事。为了让动画持续运行,mixer 需要不断更新。因此,我们需要让它在 update() 函数内进行更新。我们将它放在判断是否需要调整尺寸的代码上方:

if (mixer) {
  mixer.update(clock.getDelta());
}

mixer 的 update 方法以 clock(已在项目顶部定义)作为参数。因为是基于时间(增量)进行更新,所以动画并不会因帧率下降而变慢。如果是基于帧率执行动画,则动画的快慢取决于帧率的高低,这应该不是你想要的。

animations animations

至此,Stacy 应该能快乐的摇摆着身体!真棒!这仅是加载模型内的 10 个动画之一,我们将很快实现点击 Stacy 随机播放一个动画的效果。但接下来,我们先让模型变得更生动:让她的头部和身体朝向光标。

Part 5:朝向光标

也许你不太了解 3D(大多数情况下甚至是 2D 动画),它其实是一个被网格(mesh)包裹着的骨架(skeleton)(即骨头数组)。更改骨头的位置、比例和旋转角度,就能以有趣的方式扭曲和移动网格。进入 Stacy 的骨架,找到脖子骨头和下脊柱骨头。以视口中点为基准,这两个骨头将朝向光标进行旋转。为了实现这一点,我们需要告诉当前的“空闲”动画忽略这两个骨头。现在就让我们开始实现吧。

还记得在模型方法 traverse 里运行这段代码 if (o.isMesh) { … set shadows ..} 的那部分吗?在该 traverse 方法内,我利用 o.isBone console 所有骨头,并找到脖子和脊柱(即名字)。对于你自己制作的角色,亦可通过该方式找到骨头的准确名字。

model.traverse(o ={
if (o.isBone) {
  console.log(o.name);
}
if (o.isMesh) {
  o.castShadow = true;
  o.receiveShadow = true;
  o.material = stacy_mtl;
}

实际输出了一堆骨头,但以下才是我们想要找到的(粘贴自我的 console):

...
...
mixamorigSpine
...
mixamorigNeck
...
...

现在我们知道了脊柱(从现在开始,我们称之为腰部)和脖子的名字。

在模型的 traverse 方法,将这两个骨头赋值给相应变量(已在项目顶部声明)。

model.traverse(o ={
  if (o.isMesh) {
    o.castShadow = true;
    o.receiveShadow = true;
    o.material = stacy_mtl;
  }
  // Reference the neck and waist bones
  if (o.isBone && o.name === 'mixamorigNeck') { 
    neck = o;
  }
  if (o.isBone && o.name === 'mixamorigSpine') { 
    waist = o;
  }
});

现在,我们还需要做更多探究性工作。先前,我们创建了一个名为 idleAnim 的 AnimationClip,并将其放置在 mixer 播放。现在,我们想将脖子和腰部从这个动画中剥离,否则“空闲”动画将覆盖我们为模型创建的自定义动作。

因此,第一件需要做的是 console.log idleAnim。它是一个对象,并带有一个名为 tracks 的属性。该属性对应的值是一个长度为 156 的数组,其中,每 3 个子项代表一个骨头的动画。这 3 项分别表示骨头的位置、四元数(旋转)和比例。前三个子项是髋部位置、旋转和比例。

我们要找的是这些(粘贴自我的 console):

3: ad {name: "mixamorigSpine.position", ...
4: ke {name: "mixamorigSpine.quaternion", ...
5: ad {name: "mixamorigSpine.scale", ...

…和这些:

12: ad {name: "mixamorigNeck.position", ...
13: ke {name: "mixamorigNeck.quaternion", ...
14: ad {name: "mixamorigNeck.scale", ...

因此,在动画中,我需要通过 splice 方法移除第 3,4,512,13,14 个子项。

然而,一旦移除 3,4,5,脖子就变成了 9,10,11。这是需要注意的地方。

现在就通过代码实现以上需求。在 loader 函数的 idleAnim 下方,添加以下几行代码:

let idleAnim = THREE.AnimationClip.findByName(fileAnimations, 'idle');

// Add these:
idleAnim.tracks.splice(3, 3);
idleAnim.tracks.splice(9, 3);

我们会在后续对所有动画执行同样的操作。添加以上代码后,就意味着无论她执行何种动画,我们都拥有腰部和脖子的控制权,这使得我们能实时修改动画(为了让角色在玩空气吉时摇头,我花费了 3 小时)。

在项目底部,添加返回鼠标位置的事件。

document.addEventListener('mousemove', function(e) {
  var mousecoords = getMousePos(e);
});

function getMousePos(e) {
  return { x: e.clientX, y: e.clientY };
}

接着,我们创建 moveJoint 函数。

function moveJoint(mouse, joint, degreeLimit) {
  let degrees = getMouseDegrees(mouse.x, mouse.y, degreeLimit);
  joint.rotation.y = THREE.Math.degToRad(degrees.x);
  joint.rotation.x = THREE.Math.degToRad(degrees.y);
}

moveJoint 函数接收 3 个参数,分别是:当前鼠标的位置,需要移动的关节和允许关节旋转的角度范围。

我们在该函数顶部定义了一个名为 degrees 的变量,该变量的值来自于返回对象为 {x, y}getMouseDegrees 函数。然后,基于这个值对关节分别在 x、y 轴进行旋转。

在实现 getMouseDegrees 前,我先讲解它的实现思路。

getMouseDegress 做了这些事:判断鼠标位于视口上半部、下半部、左半部和右半部的具体位置。 例如,当鼠标在视口中点与右边界的中间,该函数会得到 right = 50%;当鼠标在视口中点与上边界的四分之一位置,该函数会得到为 up = 25%(译者注:以视口中点为起始点)。 一旦函数得到这些百分比,它会返回基于 degreelimit 的百分比。 所以,当该函数确定鼠标的位置为 75% right 和 50% up,那么会返回 x 轴 75% 的角度限值和 y 轴 50% 的角度限值。其余同理。

图示如下:

rotation_explanation

尽管我很想详细讲解这个看起来比较复杂的函数,但我怕逐行讲解会十分无聊。所以如果你感兴趣,可以结合注释进行理解。

在项目底部添加该函数:

function getMouseDegrees(x, y, degreeLimit) {
  let dx = 0,
      dy = 0,
      xdiff,
      xPercentage,
      ydiff,
      yPercentage;

  let w = { x: window.innerWidth, y: window.innerHeight };

  // Left (Rotates neck left between 0 and -degreeLimit)
  
   // 1. If cursor is in the left half of screen
  if (x <= w.x / 2) {
    // 2. Get the difference between middle of screen and cursor position
    xdiff = w.x / 2 - x;  
    // 3. Find the percentage of that difference (percentage toward edge of screen)
    xPercentage = (xdiff / (w.x / 2)) * 100;
    // 4. Convert that to a percentage of the maximum rotation we allow for the neck
    dx = ((degreeLimit * xPercentage) / 100) * -1; }
// Right (Rotates neck right between 0 and degreeLimit)
  if (x >= w.x / 2) {
    xdiff = x - w.x / 2;
    xPercentage = (xdiff / (w.x / 2)) * 100;
    dx = (degreeLimit * xPercentage) / 100;
  }
  // Up (Rotates neck up between 0 and -degreeLimit)
  if (y <= w.y / 2) {
    ydiff = w.y / 2 - y;
    yPercentage = (ydiff / (w.y / 2)) * 100;
    // Note that I cut degreeLimit in half when she looks up
    dy = (((degreeLimit * 0.5) * yPercentage) / 100) * -1;
    }
  
  // Down (Rotates neck down between 0 and degreeLimit)
  if (y >= w.y / 2) {
    ydiff = y - w.y / 2;
    yPercentage = (ydiff / (w.y / 2)) * 100;
    dy = (degreeLimit * yPercentage) / 100;
  }
  return { x: dx, y: dy };
}

一旦完成该函数的定义,我们就能使用 moveJoint。根据实际情况,我们将脖子的角度限值设为 50°,腰部的角度限值设为 30°。

更新 mousemove 事件回调函数,以包含 moveJoints

document.addEventListener('mousemove', function(e) {
    var mousecoords = getMousePos(e);
  if (neck && waist) {
      moveJoint(mousecoords, neck, 50);
      moveJoint(mousecoords, waist, 30);
  }
  });

现在,在视口范围内移动鼠标,Stacy 就会不断盯着光标!注意,“空闲”动画仍在同时执行,这是因为我们将脖子和脊柱骨头从中剥离,从而拥有了对它们的独立控制权。

这可能不是在科学上最准确的实现方式,但出来的效果却很有说服力。以上就是我们的进展,如果你遗漏了什么或者效果不一致,请仔细看看这个 pen。

See the Pen Character Tutorial - Round 2 by Kyle Wetton (@kylewetton) on CodePen.

Part 6:播放剩余动画

如前面提及,Stacy 的文件内实际上有 10 个动画,而我们仅用了其中一个。现在让我们回到 loader 函数,并找到这行代码。

mixer = new THREE.AnimationMixer(model);

在这行代码下方,我们获得除“空闲(idle)”外的 AnimationClip 列表(因为我们并不想在点击 Stacy 时随机播放的动画中包含“空闲”)。

let clips = fileAnimations.filter(val =val.name !== 'idle');

接着,与“idle”相同,将所有这些 clip 转为 Three.js AnimationClip。同时,将脖子和脊柱骨头从中剔除。最后将这些 AnimationClip 赋值给 possibleAnims(已在项目顶部定义)。

possibleAnims = clips.map(val ={
  let clip = THREE.AnimationClip.findByName(clips, val.name);
  clip.tracks.splice(3, 3);
  clip.tracks.splice(9, 3);
  clip = mixer.clipAction(clip);
  return clip;
 }
);

现在,我们拥有了能播放动画的 clipAction 数组(点击 Stacy 时)。这里需要注意的是,我们并不能简单地为 Stacy 添加一个点击事件,毕竟她不是 DOM 的一部分。这里采用射线(raycasting)实现点击,即向指定方向发射激光束,然后返回被击中的对象集合。在该案例中,激光线是从摄像机射向光标。

在 mousemove 事件上方添加该函数:

// We will add raycasting here
document.addEventListener('mousemove', function(e) {...}
window.addEventListener('click', e =raycast(e));
window.addEventListener('touchend', e =raycast(e, true));

function raycast(e, touch = false) {
  var mouse = {};
  if (touch) {
    mouse.x = 2 * (e.changedTouches[0].clientX / window.innerWidth) - 1;
    mouse.y = 1 - 2 * (e.changedTouches[0].clientY / window.innerHeight);
  } else {
    mouse.x = 2 * (e.clientX / window.innerWidth) - 1;
    mouse.y = 1 - 2 * (e.clientY / window.innerHeight);
  }
  // update the picking ray with the camera and mouse position
  raycaster.setFromCamera(mouse, camera);

  // calculate objects intersecting the picking ray
  var intersects = raycaster.intersectObjects(scene.children, true);

  if (intersects[0]) {
    var object = intersects[0].object;

    if (object.name === 'stacy') {

      if (!currentlyAnimating) {
        currentlyAnimating = true;
        playOnClick();
      }
    }
  }
}

我们添加了两个事件,分别对应 PC 和触屏。我们将 event 传入 raycast() 函数,并在触屏情况下,将 touch 参数设为 true。

在 raycast() 函数内,我们有一个 mouse 变量。若 touchtruemouse.xmouse.y 则被设为 changedTouches[0] 的坐标,反之被设为鼠标的坐标。(译者注:WebGL,坐标轴的原点在画布中心,坐标轴的范围是 -1 至 1)。

接着调用 raycaster (已在项目顶部声明为 new Raycaster 实例)的 setFromCamera 方法。这行代码表示光线从摄像机射向鼠标。

然后得到被射中的对象数组。若数组不为空,那么即可认为第一个子项就是被选中的对象。

如果选中对象的名字为 stacy,那么会执行 playOnClick()。注意,我们同时也会判断 currentlyAnimating 是否为 false,即当有动画正在执行(idle 除外)时,不会执行新动画。

raycast 函数下方,定义 playOnClick 函数。

// Get a random animation, and play it 
 function playOnClick() {
  let anim = Math.floor(Math.random() * possibleAnims.length) + 0;
  playModifierAnimation(idle, 0.25, possibleAnims[anim], 0.25);
}

基于 possibleAnims 数组长度创建一个随机数,然后调用另一个函数 playModifierAnimation。该函数接收的参数有:idle(from,即从 idle 开始),从 idle 到新动画(possibleAnimsanim)的过渡时间;最后一个参数是从当前动画回到 idle 的过渡时间。在 playOnClick 函数下方,我们添加 playModifierAnimation

function playModifierAnimation(from, fSpeed, to, tSpeed) {
  to.setLoop(THREE.LoopOnce);
  to.reset();
  to.play();
  from.crossFadeTo(to, fSpeed, true);
  setTimeout(function() {
    from.enabled = true;
    to.crossFadeTo(from, tSpeed, true);
    currentlyAnimating = false;
  }, to._clip.duration * 1000 - ((tSpeed + fSpeed) * 1000));
}

该函数做的第一件事是 重置 to 动画,即将要播放的动画。同时,我们将其 播放次数 设为 1 次,因为一旦动画播放完成(也许我们之前已播放过),它需要重置后才能再次播放。然后,调用 play 方法。

每个 clipAction 实例都有一个 crossFadeTo 方法,我们使用它来实现 from(idle) 到新动画的过渡,并且过渡时间为 fSpeed(即 from speed)。

至此,函数已有拥有了从 idle 过渡到新动画的能力。

接着,我们开启了一个定时器,用于将当前动画恢复到 from 动画(即 idle),同时将 currentlyAnimating 设置 false(这样就允许再次点击 Stacy)。setTimeout 的时间计算方法为:动画长度(* 1000 是因为过渡时间以秒而不是毫秒为单位)减去动画切入和切出的过渡时间(同样以秒为单位设置,所以需要 * 1000)来得到。

注意,脖子和脊柱骨头均不受动画控制,这使得我们能够在动画过程中旋转它们。

本教程到此已算结束,若遇到问题,请参考以下完整项目。

See the Pen Character Tutorial - Final by Kyle Wetton (@kylewetton) on CodePen.

如果你对模型和动画本身的工作感兴趣,那么我将在最后一节介绍一些基础知识,希望能拓展你的视野。

Part 7:创建一个模型文件(选读章节)

以下操作均基于最新稳定版 Blender 2.8。

在开始之前,请记住我曾经提到过的,尽管可以在 GLTF 文件(从 Blender 导出的格式)中包含纹理文件,但我遇到的问题是 Stacy 的纹理确实很暗。这与 GLTF 需要 sRGB 格式有关,尽管我尝试在 Photoshop 中进行转换,但这仍不起作用。在不能保证该文件格式的纹理质量下,我的做法是导出没有纹理的文件,然后再通过 Three.js 添加。除非你的项目非常复杂,否则我建议这样做。

不管怎样,一个 T 姿势的标准角色网格(mesh)就是我们在 Blender 起始点。之所以要让角色摆成 T 姿势,是因为 Mixamo 会基于此生成骨架,敬请期待。

blender-1

然后以 FBX 格式导出模型。

blender-2

然后可以离开 Blender 一阵子。

www.mixamo.com 网站提供了许多免费动画,可用于各种场景,而浏览者以独立游戏开发者居多。另外,该 Adobe 服务与 Adobe Fuse 关系密切,后者实际上是角色创建软件。该网站是免费使用的,但需要一个 Adobe 帐户(免费是指你不需要订阅 Creative Cloud)。因此,创建账号并登录。

你要做的第一件事是上传角色。这是我们从 Blender 导出的 FBX 文件。上传完成后,Mixamo 将自动启用 Auto-Rigger。

mixamo-3

按照说明将标记放置在模型的关键位置上。一旦 Auto-Rigger 完成,你将会在面板上看到你的角色在运动。

mixamo-4

Mixamo 已为你的模型创建骨架了,这就是本教程所谈及的骨架。

点击 “Next”,然后在左上方导航条中选择 “Animations”。让我们搜索 “idle” 动画作为开始,使用搜索框并输入 "idle"。本教程使用的是 “Happy idle”。

点击任意动画进行预览。当然该网站还有很多有趣的动画。对于本项目,结束动作与衔接动作的脚部位置尽可能相同,即其位置与空闲动画基本类似。因为结束姿势与下一个动画的开始姿势相似时,过渡会看起来更自然。

mixamo-5

对 “idle” 动画感到满意后,请点击 “Downlod Character”。格式应为 FBX,并且 skin 应设置为 “With Skin”。其余设置保留为默认值。下载此文件,并保持 Mixamo 的打开状态。

返回到 Blender 中,将该文件导入到一个新空会话中(删除新会话附带的光源,摄像机和立方体)。

点击 play 按钮(如果未看到时间轴(timeline),将任意一个面板的 Editor Type 切换为 Timeline,若仍不懂,建议看看 Blender 的 界面介绍

mixamo-6

此时,若想重命名动画,则将 Editor Type 更改为 “Dope Sheet”,并将二级菜单设置为 “Action Editor”。

dope-sheet

点击 “+ New” 旁的下拉框,选择从 Mixamo 得到动画。此时可以在输入框内重命名,我们将它改为 “idle”。

mixamo-6-1

mixamo-6-1-1 点击 “x” 可看到 “+ select” 标识

如果现在将该文件导出为 GLTF,那么在 gltf.animations 内就有一个名为 idle 的动画。记住,该文件同时拥有 gltf.animations 和 gltf.scene。

在导出之前,我们需要对角色对象进行重命名。设置如下所示。

rename

请注意,在下方的子节点 stacy 是 JavaScript 中引用的对象名称。

现在我们仍不进行导出,相反,我将快速向你展示如何添加新动画。回到 Mixamo,我选择了 “Shake Fist”(挥拳)动画。下载此文件,我们仍保留皮肤,可能有人会说这次不需要保留皮肤。但我发现如果不保留皮肤会出现奇怪的状况。

将其导入 Blender。

blender-5

此时,我们有两个 Stacy,一个叫 Armature,另一个是我们想保留的 Stacy。我们将删除 Armature,但首先要将其 Shake Fist 动画移至 Stacy。让我们回到 “Dope Sheet” “Animation Editor”。

现在,你会看到在 idle 旁有一个新动画,让我们选择它,并将其重命名为 shakefist。

blender-6

blender-7

保持当前面板的 “Dope Sheet” “Action Editor”,并将另一个未使用的面板(或拆分屏幕以创建一个新的面板。同样,阅读 Blender 界面介绍教程有助于理解这段话)设置 Editor Type 为非线性动画(NLA)。

blender-9

点击 stacy,然后点击 idle 动画旁边的 “PUSH DOWN” 按钮。这样就能在已添加了 idle 动画基础上,创建新轨道以添加 shakefist 动画。

处理前,再次点击 stacy 名字:

blender-11

回到 Animation Editor 面板,并从下拉列表中选择 “shaffist”。

blender-12

最后,在 NLA 面板中点击 shaffist 旁边的 “Push Down” 按钮。

blender-13

应该留下这些元素:

blender-15-1

blender-14

我们已经将动画从 Armature 转移到 Stacy,现在可以删除 Armature 了。

blender-15

烦人的是,Armature 会将其子网格物体落到场景中,也将其删除。

blender-16

重复以上步骤添加新动画(我相信做得越多,疑惑越少,效率越高)。

导出文件:

blender-17

这是使用新模型的 pen!(需要注意的是:Stacy 的缩放比例与之前有所不同,所以在该 pen 中进行了调整。尽管到现在我对那些经 Mixamo 添加骨架并从 Blender 导出的模型的缩放比例仍琢磨不透,但在 Three.js 中能轻易地解决这个问题)。

See the Pen Character Tutorial - Remix by Kyle Wetton (@kylewetton) on CodePen.