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

@JChehe 2019-12-19 15:49:16发表于 JChehe/blog

原文: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(译者注:会将 .wrapper 内的 canvas 迁移至 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

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

至此,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", ...

…and this:

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

我们添加了两个事件,分别对应桌面和触摸屏。我们将 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 到新动画(possibleAnims[anim])的过渡(blend)速度;最后一个参数是从当前动画回到“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

You aren’t going to need the current Blender session any more, but more on that soon.
然后,你就可以离开 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,并且皮肤应设置为“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 动画。

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

blender-12

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

blender-13

应该还剩以下几步:

blender-15-1

blender-14

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

blender-15

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

blender-16

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

I’m going to export my file though:
导出文件:

blender-17

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

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

完!