Three.js角色动画实战
曾经有过一个专门介绍你的作品的个人网站,想知道你是否应该在其中的某个地方放一张自己的照片? 我最近想我应该更进一步,添加一个完全交互式的 3D 版本的我自己,当用户在我的屏幕上导航时,它会观察用户的光标。 如果这还不够,你甚至可以点击我,我会做一些事情。 本教程向你展示如何对我们选择的名为 Stacy 的模型执行相同的操作。
这是演示,单击 Stacy,然后在 Pen 周围移动鼠标以观看她跟随它。
我们将使用 Three.js,并且我假设你已经掌握了 JavaScript。
我们使用的模型加载了十个动画,在本教程的底部,我将解释它是如何设置的。 这是在 Blender 中完成的,动画来自 Adobe 的免费动画库 Mixamo。
1、HTML 和 CSS 项目入门
让我们去掉少量的 HTML 和 CSS。 这支笔拥有你需要的一切。 跟随这支笔的分支,或者将 HTML 和 CSS 从这里复制到其他地方的空白项目中。
HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<!-- Imporant meta information to make the page as rigid as possible on mobiles, to avoid unintentional zooming on the page itself -->
<meta name="viewport" content="width=device-width, height=device-height, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Character Tutorial</title>
</head>
<body>
<!-- The loading element overlays all 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> -->
<div class="wrapper">
<!-- The canvas element is used to draw the 3D scene -->
<canvas id="c"></canvas>
</div>
</body>
</html>
<!-- The main Three.js file -->
<script src='https://cdnjs.cloudflare.com/ajax/libs/three.js/108/three.min.js'></script>
<!-- This brings in the ability to load custom 3D objects in the .gltf file format. Blender allows the ability to export to this format out the box -->
<script src='https://cdn.jsdelivr.net/gh/mrdoob/Three.js@r92/examples/js/loaders/GLTFLoader.js'></script>
CSS:
body,
html {
margin: 0;
padding: 0;
background: #25252B;
}
* {
touch-action: manipulation;
}
*,
*:before,
*:after {
box-sizing: border-box;
}
body {
position: relative;
width: 100%;
height: 100vh;
}
.wrapper {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
#c {
position: absolute;
top: 0;
width: 100%;
height: 100%;
display: block;
}
.loading {
position: fixed;
z-index: 50;
width: 100%;
height: 100%;
top: 0; left: 0;
background: #f1f1f1;
display: flex;
justify-content: center;
align-items: center;
}
.loader{
-webkit-perspective: 120px;
-moz-perspective: 120px;
-ms-perspective: 120px;
perspective: 120px;
width: 100px;
height: 100px;
}
.loader:before{
content: "";
position: absolute;
left: 25px;
top: 25px;
width: 50px;
height: 50px;
background-color: #9bffaf;
animation: flip 1s infinite;
}
@keyframes flip {
0% {
transform: rotate(0);
}
50% {
transform: rotateY(180deg);
}
100% {
transform: rotateY(180deg) rotateX(180deg);
}
}
我们的 HTML 包含一个加载动画(目前已注释掉,直到我们需要它)、一个包装器 div 和我们最重要的 canvas 元素。 画布是 Three.js 用来渲染场景的,CSS 将其设置为 100% 视口大小。 我们还在 HTML 文件的底部加载了两个依赖项:Three.js 和 GLTFLoader(GLTF 是我们的 3D 模型导入的格式)。 这两个依赖项都可以作为 npm 模块使用。
CSS 还包含少量居中样式,其余只是加载动画; 真的仅此而已。 你现在可以折叠 HTML 和 CSS 面板,我们将在本教程的其余部分深入研究这一点。
2、构建我们的场景
在我的上一个教程中,我发现自己让你上下运行文件,在顶部添加变量,这些变量需要在几个不同的地方共享。 这次我会提前把这些都给你,等我们用到的时候再告诉你。 如果你好奇的话,我已经包括了每一个是什么的解释。 于是,我们的项目就这样开始了。 在你的 JavaScript 中添加这些变量。 请注意,因为这里有一点在工作,否则会在全局范围内,我们将整个项目包装在一个函数中:
(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。 这包括场景、渲染器、相机、灯光和更新功能。 更新功能在每一帧上运行。
让我们在 init()
函数中完成所有这些。 在我们的变量下,在我们的函数范围内,我们添加了init 函数:
init();
function init() {
}
在我们的 init 函数中,让我们引用画布元素并设置背景颜色,我在本教程中选择了非常浅的灰色。 请注意,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);
接下来是渲染器,我们创建一个新的渲染器并传递一个带有画布引用和其他选项的对象。 我们在这里使用的唯一选项是启用抗锯齿。 我们启用 shadowMap 以便我们的角色可以投射阴影,我们将像素比设置为设备的像素比,这样移动设备才能正确渲染。 否则画布将在高密度屏幕上显示像素化。 最后,我们将渲染器添加到我们的文档主体中。
// 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 需要的前两件事。 接下来是相机。 让我们创建一个新的透视相机。 我们将视野设置为 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.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 个单位; 稍后可以随意试验。
我们的方向灯需要一个位置设置; 我选择的那个感觉不错,所以让我们从那个开始吧。 我们启用投射阴影的能力,并设置阴影分辨率。 其余的阴影与世界的灯光视图有关,这对我来说有点模糊,但知道可以调整变量 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 个单位(没有任何特别的原因,只是它确实确保了我们的无缝背景)。
然后我们为场景创建材质。 这是新的。 在本教程中我们只有几种不同的材质,但现在知道将几何体和材质组合成一个网格就足够了,这个网格是我们场景中的 3D 对象。 我们现在制作的网格是一个非常大的平面,旋转后平放在地面上(好吧,就是地面)。 它的颜色设置为 0xeeeeee,比我们的背景稍暗。 为什么? 因为我们的灯光照在这一层,但是我们的灯光不影响背景。 这是我手动调整的颜色,为我们提供了无缝场景。 完成后玩一下。
我们的地板是一个结合了几何体和材质的网格。 通读我们刚刚添加的内容,我想你会发现一切都是不言自明的。 我们将楼层向下移动 11 个单位,一旦我们载入角色,这就有意义了。
现在就是我们的 init() 函数。
Three.js 依赖的一个关键方面是更新功能,它运行每一帧,如果你曾经接触过 Unity,它类似于游戏引擎的工作方式。 这个函数需要放在我们的 init() 函数之后而不是里面。 在我们的更新函数中,渲染器渲染场景和相机,然后再次运行更新。 请注意,我们在函数本身之后立即调用该函数。
function update() {
renderer.render(scene, camera);
requestAnimationFrame(update);
}
update();
我们的场景现在应该打开了。 画布呈现浅灰色; 我们在这里实际看到的是背景和地板。 你可以通过将地板材质颜色更改为 0xff0000 来对此进行测试。 不过记得改回来!
我们将在下一部分加载模型。 在我们这样做之前,我们的场景还需要一件事。 作为 HTML 元素的画布将按原样调整大小,高度和宽度在 CSS 中设置为 100%。 但是,场景也需要注意调整大小,以便它可以使所有内容保持比例。 在我们调用更新函数(不在其中)的下方,添加此函数。 如果你愿意,请仔细阅读它,但本质上它正在做的是不断检查我们的渲染器是否与我们的画布大小相同,一旦不同,它就会将 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;
}
我们将在更新功能中使用它。 找到这些行:
renderer.render(scene, camera);
requestAnimationFrame(update);
在这些行之上,我们将通过调用我们的函数来检查是否需要调整大小,并更新相机纵横比以匹配新大小。
if (resizeRendererToDisplaySize(renderer)) {
const canvas = renderer.domElement;
camera.aspect = canvas.clientWidth / canvas.clientHeight;
camera.updateProjectionMatrix();
}
我们完整的更新函数现在应该是这样的:
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) { ... }
到目前为止,这是我们的整个项目。 接下来我们要加载模型。
3、添加模型
我们的场景非常稀疏,但它已经设置好,我们已经调整大小,我们的灯光和相机正在工作。 让我们添加模型。
在我们的 init() 函数的顶部,在引用画布之前,让我们引用模型文件。 这是 GLTf 格式 (.glb),Three.js 支持一系列 3D 模型格式,但这是它推荐的格式。 我们将使用我们的 GLTFLoader 依赖项将这个模型加载到我们的场景中。
const MODEL_PATH = 'https://s3-us-west-2.amazonaws.com/s.cdpn.io/1376484/stacy_lightweight.glb';
仍然在 init() 函数中,在我们的相机设置下面,让我们创建一个新的加载器:
var loader = new THREE.GLTFLoader();
这个加载器使用一种称为加载的方法。 它有四个参数:模型路径、模型加载后调用的函数、加载期间调用的函数以及捕获错误的函数。
让我们现在添加:
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);
}
);
注意注释“这里会发生很多事情”,这是我们的模型加载后运行的函数。 除非我另有说明,否则所有前进的内容都添加在此功能中。
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);
}
);
请注意,模型已经在我们项目的顶部进行了初始化。
现在应该在我们的场景中看到一个小人物。
这里有几件事:
- 我们的模型真的很小; 3D 模型就像矢量,你可以缩放它们而不会丢失任何定义; Mixamo 输出的模型非常小,因此我们需要将其放大。
- 你可以在 GLTF 模型中包含纹理,我没有这样做的原因有很多,首先是解耦它们允许在托管资产时使用更小的文件大小,另一个是与颜色空间有关,我将介绍更多 在本教程底部的部分中,该部分介绍了如何设置 3D 模型。
我们过早地添加了模型,所以在 scene.add(model)
之上,让我们做更多的事情。
首先,我们将使用模型的遍历方法找到所有网格,并启用投射和接收阴影的能力。 这是这样做的。 同样,这应该在 scene.add(model)
之上:
model.traverse(o => {
if (o.isMesh) {
o.castShadow = true;
o.receiveShadow = true;
}
});
然后,我们将模型的比例设置为其初始大小的统一 7 倍。 在我们的遍历方法下面添加:
// Set the models initial scale
model.scale.set(7, 7, 7);
最后,让我们将模型向下移动 11 个单位,使其站在地板上。
model.position.y = -11;
完美,我们已经加载了我们的模型。 现在让我们加载纹理并应用它。 这个模型带有纹理,模型已经在 Blender 中映射到这个纹理。 此过程称为 UV 映射。 随意下载图像本身进行查看,如果你想探索制作自己的角色的想法,请了解有关 UV 贴图的更多信息。
我们之前引用了加载程序; 让我们在这个参考之上创建一个新的纹理和材质:
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,我们在这里为我们的模型材料使用了几个新选项。 首先,我们将 stacy_txt 纹理传递给 map 属性。 其次,我们打开蒙皮,这对动画模型至关重要。 我们使用 stacy_mtl 引用此材质。
好的,我们已经有了纹理材质,我们的文件场景 (gltf.scene) 只有一个对象,所以,在我们的遍历方法中,让我们在使我们的对象能够投射和接收阴影的行下面再添加一行:
model.traverse(o => {
if (o.isMesh) {
o.castShadow = true;
o.receiveShadow = true;
o.material = stacy_mtl; // Add this line
}
});
就这样,我们的模型变成了完全真实的角色 Stacy。
虽然她有点死气沉沉。 下一节将处理动画,但现在你已经处理了几何体和材质,让我们使用我们学到的知识让场景更有趣一点。 向下滚动到添加楼层的位置,我会在那儿等你。
在地板下方,作为 init() 函数的最后几行,让我们添加一个圆圈。 这实际上是一个使用 BasicMaterial 的 3D 球体,很大但距离很远。 我们之前使用的材料称为 PhongMaterials,它可以发光,最重要的是可以接收和投射阴影。 但是,BasicMaterial 不能。 因此,将这个球体添加到你的场景中,以创建一个平面圆圈,更好地勾勒出 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);
将颜色更改为你想要的任何颜色!
4、让Stacy动起来
在我们开始之前,你可能已经注意到 Stacy 需要一段时间才能加载。 这可能会造成混淆,因为在她加载之前,我们所看到的只是页面中间的一个彩色点。 我提到在我们的 HTML 中我们有一个被注释掉的加载器。 转到 HTML 并取消注释此标记。
<!-- 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>
然后再次在我们的加载器函数中,一旦使用 scene.add(model) 将模型添加到场景中,在它下面添加这一行。 loaderAnim 已经在我们项目的顶部被引用。
loaderAnim.remove();
我们在这里所做的就是在将 Stacy 添加到场景后移除加载动画覆盖。 保存然后刷新,您应该会看到加载程序,直到页面准备好显示 Stacy。 如果模型被缓存,页面可能加载得太快而看不到它。
无论如何,到动画!
我们仍在我们的加载函数中,我们将创建一个新的 AnimationMixer,AnimationMixer 是场景中特定对象的动画播放器。 其中一些可能看起来很陌生,并且可能超出本教程的范围,但如果你想了解更多信息,请查看 AnimationMixer 上的 Three.js 文档页面。 你不需要了解我们在这里处理的内容就可以完成本教程。
将其添加到删除加载器的行下方,并传入我们的模型:
mixer = new THREE.AnimationMixer(model);
请注意,我们项目的顶部引用了Mixer。
在这一行下面,我们将创建一个新的 AnimationClip,我们正在查看我们的 fileAnimations 以找到一个名为“idle”的动画。 这个名字是在 Blender 中设置的。
let idleAnim = THREE.AnimationClip.findByName(fileAnimations, 'idle');
然后我们在mixer中使用一个名为 clipAction 的方法,并传入我们的 idleAnim。 我们称这个 clipAction 为空闲。
最后,我们告诉 idle 去播放:
idle = mixer.clipAction(idleAnim);
idle.play();
虽然它还没有开始播放,但我们还需要一件事。 需要更新mixer才能使其在动画中连续运行。 为了做到这一点,我们需要告诉它在我们的 update() 函数中更新。 在顶部添加这个,在我们的调整大小检查之上:
if (mixer) {
mixer.update(clock.getDelta());
}
更新采用我们的时钟(在我们项目的顶部引用了一个时钟)并将其更新为该时钟。 这样一来,如果帧速率变慢,动画就不会变慢。 如果以帧速率运行动画,它会与帧相关联来确定它运行的快慢,这不是你想要的。
Stacy 应该很高兴地并排摇摆吧! 做得好! 这只是我们模型文件中加载的 10 个动画中的一个,很快我们会选择一个随机动画在你单击 Stacy 时播放,但接下来,让我们的模型更加生动,让她的头和身体指向我们的光标。
5、注视光标
如果你不太了解 3D(或者在大多数情况下甚至是 2D 动画),它的工作方式是有一个骨架(或一组骨骼)扭曲网格。 这些骨骼的位置、比例和旋转随时间变化,以有趣的方式扭曲和移动我们的网格。 我们将钩入 Stacys 骨架 (ek) 并参考她的颈骨和底部脊柱骨。 然后我们将根据光标相对于屏幕中间的位置来旋转这些骨骼。 不过,为了让我们这样做,我们需要告诉我们当前的空闲动画忽略这两个骨骼。 让我们开始吧。
还记得我们在模型遍历方法中所说的 if (o.isMesh) { … set shadows ..} 部分吗? 在这个遍历方法中(不要这样做),也可以使用o.isBone。 我记录了所有的骨头,找到了脖子和脊椎的骨头,以及它们的名字。 如果你正在制作自己的角色,将需要执行此操作以找到您的骨骼的确切名称字符串。 看看这里……(同样不要将其添加到我们的项目中)
model.traverse(o => {
if (o.isBone) {
console.log(o.name);
}
if (o.isMesh) {
o.castShadow = true;
o.receiveShadow = true;
o.material = stacy_mtl;
}
我得到了很多骨骼的输出,但我试图找到这些骨骼的输出(这是从我的控制台粘贴的):
...
...
mixamorigSpine
...
mixamorigNeck
...
...
所以现在我们知道我们的脊柱(从这里开始称为腰部)和我们的脖子名称。
在我们的模型遍历中,让我们将这些骨骼添加到已经在项目顶部引用的颈部和腰部变量中。
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,然后将其发送到我们的混音器进行播放。 我们想从这个动画中剪掉颈部和骨骼轨迹,否则我们的空闲动画将覆盖我们尝试在模型上手动创建的任何操作。
所以我做的第一件事是控制台日志 idleAnim。 它是一个对象,具有一个名为 tracks 的属性。 tracks 的值是一个包含 156 个值的数组,每 3 个值代表单个骨骼的动画。 这三个是骨骼的位置、四元数(旋转)和比例。 所以前三个值是臀部位置、旋转和缩放。
我一直在寻找的是这个(从我的控制台粘贴):
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", ...
所以在我们的动画中,我想拼接轨道数组以删除 3、4、5 和 12、13、14。
然而,一旦我拼接 3,4,5 ...... 我的脖子变成了 9、10、11。 要记住的事情。
让我们现在开始吧。 在我们在加载程序函数中引用 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 函数采用三个参数,当前鼠标位置、我们要移动的关节以及允许关节旋转的限制(以度为单位)。 这叫做 degreeLimit,记住这一点,我很快就会谈到它。
我们在顶部引用了一个名为 degrees 的变量,度数来自一个名为 getMouseDegrees 的函数,该函数返回一个 {x, y} 的对象。 然后我们使用这些度数在 x 轴和 y 轴上旋转关节。
在我们添加 getMouseDegrees 之前,我想解释一下它的作用。
getMouseDegrees 这样做:它检查屏幕的上半部分、屏幕的下半部分、屏幕的左半部分和屏幕的右半部分。 它以屏幕中间和每个边缘之间的百分比确定鼠标在屏幕上的位置。
例如,如果鼠标位于屏幕中间和右边缘之间。 该函数确定 right = 50%,如果鼠标距离中心向上四分之一,则该函数确定 up = 25%。
一旦函数具有这些百分比,它就会返回 degreelimit 的百分比。
因此该函数可以确定您的鼠标是向右的 75% 和向上的 50%,并返回 x 轴上的度数限制的 75% 和 y 轴上的度数限制的 50%。 左边和右边一样。
这是一个视觉效果:
我想解释一下,因为这个函数看起来很复杂,我不会让你厌烦每一行,但我已经注释了每一步,如果你愿意的话,你可以进一步研究它。
将此功能添加到项目的底部:
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 都会看到你的光标! 请注意空闲动画是如何仍在运行的,但是因为我们剪断了颈部和脊椎骨骼(糟糕),所以我们能够独立控制它们。
这可能不是最科学准确的方法,但它看起来确实足以产生我们想要的效果。 这是我们目前的进展,如果你觉得你错过了什么或者你没有得到同样的效果,请深入研究这个代码。
6、其余的动画
正如我之前提到的,Stacy 实际上有 10 个动画加载到文件中,而我们只使用了其中一个。 让我们回到加载器函数并找到这一行。
mixer = new THREE.AnimationMixer(model);
在此行下方,我们将获得一个非空闲的 AnimationClip 列表(我们不想在单击 Stacy 时随机选择空闲作为选项之一)。 我们这样做:
let clips = fileAnimations.filter(val => val.name !== 'idle');
下面,我们将把所有这些剪辑转换成 Three.js AnimationClips,就像我们在闲置时所做的一样。 我们还将从骨架中拼接出颈部和脊椎骨骼,并将所有这些 AnimationClips 添加到一个名为 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;
}
);
现在,我们有了一组可以在单击 Stacy 时播放的 clipActions。 不过这里的技巧是我们不能在 Stacy 上添加一个简单的点击事件监听器,因为她不是我们 DOM 的一部分。 我们将改为使用光线投射,这实际上意味着在一个方向上发射激光束并返回它击中的物体。 在这种情况下,我们从我们的相机在我们的光标方向拍摄。
让我们将其添加到我们的 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();
}
}
}
}
我们添加了两个事件侦听器,一个用于桌面,一个用于触摸屏。 我们将事件传递给 raycast() 函数,但对于触摸屏,我们将 touch 参数设置为 true。
在 raycast() 函数中,我们有一个名为 mouse 的变量。 这里我们将 mouse.x 和 mouse.y 设置为 changedTouches[0] 位置,如果触摸为真,或者只返回鼠标在桌面上的位置。
接下来我们在 raycaster 上调用 setFromCamera,它已经在我们项目的顶部设置为一个新的 Raycaster,可以使用了。 这条线本质上是从相机到鼠标位置的光线投射。 请记住,我们每次单击时都会这样做,所以我们正在用鼠标向 Stacy 发射激光(全新句子?)。
然后我们得到一组相交的对象; 如果有的话,我们将第一个被击中的对象设置为我们的对象。
我们检查对象名称是否为“stacy”,如果对象名为“stacy”,我们将运行一个名为 playOnClick() 的函数。 请注意,在我们继续之前,我们还会检查变量 currentlyAnimating 是否为 false。 我们打开和关闭此变量,以便我们无法在当前正在运行的动画(空闲除外)时运行新动画。 我们将在动画结束时将其恢复为 false。 这个变量在我们项目的顶部被引用。
好的,所以 playOnClick。 在我们的 rayasting 函数下面,添加我们的 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);
}
这只是在 0 和 possibleAnims 数组的长度之间选择一个随机数,然后我们调用另一个名为 playModifierAnimation 的函数。 此函数接受空闲(我们从空闲移动)、从空闲混合到新动画的速度(possibleAnims[anim]),最后一个参数是从我们的动画混合回空闲的速度。 在我们的 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));
}
我们做的第一件事是重置动画,这是即将播放的动画。 我们还将它设置为只播放一次,这样做是因为一旦动画完成它的过程(也许我们之前播放过),它需要重置才能再次播放。 然后我们播放它。
每个 clipAction 都有一个称为 crossFadeTo 的方法,我们使用它使用我们的第一个速度(fSpeed,或从速度)从(空闲)淡入到我们的新动画。
在这一点上,我们的功能已经从空闲状态变为我们的新动画。
然后我们设置一个超时函数,我们将我们的 from animation (idle) 变回 true,我们交叉淡入淡出回到 idle,然后我们将 currentlyAnimating 切换回 false(允许再次点击 Stacy)。 setTimeout 的时间是通过结合我们的动画长度(* 1000,因为这是以秒而不是毫秒为单位)计算的,并删除淡入和淡出该动画所花费的速度(也以秒为单位,所以又是 * 1000)。 这给我们留下了一个从空闲状态淡出的功能,播放动画,一旦它完成,淡出回到空闲状态,允许再次点击 Stacy。
请注意,我们的颈部和脊柱骨骼没有受到影响,这让我们仍然能够控制它们在动画期间的旋转方式!
7、创建模型文件(可选)
如果你继续,你将需要 Blender 来完成这部分。 我推荐 Blender 2.8,最新的稳定版本。
在我开始之前,记得我提到过虽然你可以在你的 GLTF 文件中包含纹理文件(你从 Blender 导出的格式),但我遇到了 Stacy 的纹理真的很暗的问题。 这与 GLTF 需要 sRGB 格式这一事实有关,虽然我尝试在 Photoshop 中转换它,但它仍然没有发挥作用。 你不能保证你将获得的文件类型作为纹理,所以我设法解决这个问题的方法是导出没有纹理的文件,然后让 Three.js 本地添加它。 我建议这样做,除非你的项目非常复杂。
无论如何,这就是我在 Blender 中的开始,只是一个 T 姿势角色的标准网格。 你的角色绝对应该处于 T 姿势,因为 Mixamo 将为我们生成骨架,所以它期待这一点。
你应该以 FBX 格式导出模型。
你将不再需要当前的 Blender 会话,但很快就会有更多。
前往 www.mixamo.com,这个网站有一堆免费的动画,用于各种各样的事情,独立游戏开发者经常浏览,这项 Adobe 服务与 Adobe Fuse 密切相关,它本质上是一个角色 造物主软件。 这是免费使用的,但你需要一个 Adobe 帐户(免费是指你不需要 Creative Cloud 订阅)。 所以创建一个并登录。
要做的第一件事是上传你的角色。 这是我们从 Blender 导出的 FBX 文件。 上传完成后,Mixamo 将自动启动 Auto-Rigger 功能。
按照说明将标记放置在模型的关键区域。 自动装配完成后,你将看到一个面板,其中包含你的角色动画!
Mixamo 现在已经为你的模型创建了骨架,这是我们在本教程中连接的骨架。
单击下一步,然后选择左上角的动画选项卡。 让我们先找到一个空闲动画,使用搜索栏并输入“idle”。 如果你有兴趣,我们在本教程中使用的称为“Happy idle”。
单击任何动画将预览它,浏览此站点以查看其他一些疯狂的动画。 但重要的一点是:这个特定的项目最适合动画,脚在它们开始的地方结束,位置类似于我们的空闲动画,因为我们正在交叉淡入淡出这些,当结束姿势类似于 下一个动画开始姿势,反之亦然。
对闲置动画感到满意后,单击下载角色。 你的格式应为 FBX,皮肤应设置为 With Skin。 其余保持默认。 下载此文件。 保持 Mixamo 打开。
返回 Blender,将此文件导入一个新的空会话(删除新 Blender 会话附带的灯光、相机和默认立方体)。
如果你点击播放按钮:
此时你想要重命名动画,因此更改为名为 Dope Sheet 的编辑器类型,并选择动作编辑器作为子部分。
单击 + New 旁边的下拉菜单并选择 Mixamo 包含在此文件中的动画。 此时可以在输入字段中重命名它,我们称之为“idle”。
现在,如果我们将此文件导出为 GLTF,则在 gltf.animations 中将有一个名为 idle 的动画。 请记住,我们的文件中同时包含 gltf.animatons 和 gltf.scene。
不过在我们导出之前,我们需要适当地重命名我们的角色对象。 我的设置看起来像这样。
请注意,底部的 child stacy 是我们的 JavaScript 中引用的对象名称。
我们先不导出,我会快速向你展示如何添加新动画。 回到 Mixamo,我选择了 Shake Fist 动画。 也下载这个文件,我们还是要保留皮肤,其他人可能会说这次你不需要保留皮肤,但我发现我的骨架在我不保留的时候做了奇怪的事情。
让我们将其导入 Blender。
此时我们有两个 Stacys,一个叫做 Armature,另一个我们想要保留,Stacy。 我们将删除 Armature 动画,但首先我们要将其当前的 Shake Fist 动画移至 Stacy。 让我们回到我们的摄影表 > 动画编辑器。
你会看到我们现在在空闲旁边有一个新动画,让我们选择它,然后将它重命名为 shakefist。
我们想调出最后一个编辑器类型,让你的摄影表 > 动作编辑器保持打开状态,并在另一个未使用的面板中(或拆分屏幕以创建一个新面板,如果你通过 Blenders UI 的介绍,它再次有所帮助)。
我们希望新的编辑器类型为非线性动画 (NLA)。
点击Stacy。 然后单击空闲动画旁边的下推按钮。 我们现在已经将空闲添加为动画,并创建了一个新轨道来添加我们的握拳动画。
令人困惑的是,在我们继续之前,你想再次点击 stacy 的名字。
我们这样做的方法是返回我们的动画编辑器并从下拉列表中选择 shakefist。
最后,我们可以使用 NLA 编辑器中 shakefist 旁边的下推按钮。
你应该留下这个:
我们已将动画从骨架转移到 Stacy,现在可以删除骨架。
令人讨厌的是,骨架会将其子网格放入场景中,也删除它。
现在可以重复这些步骤来添加新的动画(我向你保证,它越不那么混乱,而且速度越快)。
不过我要导出我的文件:
原文链接:How to Create an Interactive 3D Character with Three.js
BimAnt翻译整理,转载请标明出处