Three.js - TRS和3D模型动画系统深入

655 阅读19分钟

物体的TRS浅析

所谓的TRS是指存储在.position属性中的translation存储在.rotation属性中的rotation存储在.scale属性中的scale;通过scene.add()添加到场景中的每个对象都有这些属性,包括灯光、网格和相机,但是需要注意的是材质和几何图形没有这些;

世界空间和局部空间 世界空间和局部空间.png

threeJS中的.add方法是可以让某些派生类也继承到该方法,如light.addmesh.addcamera.add等,这也就意味着可以将场景中的对象彼此相互添加,从而间接的创建了一个以场景scene为顶部的树结构,这种树结构被称为场景图,其中每个树节点需要遵循的规则是场景中的每个对象(顶级场景除外)只有一个父对象,并且可以有任意数量的子对象,在将一个对象移动(可以通过添加add实现移动)到另一个场景中时,之前该对象的父对象就会失效,进而更新为移动之后的所处空间或对象,在移动完成后其自身的位置也会随之重新计算;
当使用scene.add向场景中添加对象时,我们将这个对象嵌入到场景中的坐标系世界空间中,当我们移动对象时,它会相对于世界空间移动;而当我们将对象添加到场景中更深的另一个对象时,就将子对象嵌入到了父对象的本地空间中,此时移动子对象就是相对于父对象的坐标系进行移动了

translation平移

可以通过更改对象的.position属性来执行平移,平移对象会将其移动到其直接父对象坐标系中的新位置;
threeJS中有一个表示3D向量的特殊类,称为Vector3,这个类有.x.y.z属性和方法.set来操作这些属性,每当创建任何场景对象时,例如MeshVector3都会被自动创建并存储在.position中,当然也是可以直接创建Vector3实例;

import { Vector3 } from 'three';
const vector = new Vector3(1, 2, 3);
// 可以在new实例化时不传初始化数据,默认是原点(0,0,0)

vector.x = 5;
vector.x; // 5

vector.set(7, 7, 7);
vector.x; // 7
vector.y; // 7
vector.z; // 7

scale缩放

.position一样,.scale也是存储在Vector3中,对象的初识缩放比例是(1,1,1);
当值为负值时,除了使得对象变大或变小之外,还会镜像对象;小于0且大于-1的值将镜像并挤压对象,值小于-1将镜像和拉伸对象

const mesh = new Mesh();

mesh.scale = new Vector3(1, 1, 1);
mesh.scale.set(0.5,0.5,0.5)
mesh.scale.y = 0.25
mesh.scale.z = 0.5

要在保持其缩放比例的同时镜像对象,请对所有三个轴使用相同的值,但将其中一个设为负值
将对象缩小到十分之一大小并在X轴镜像 mesh.scale.set(-0.1,0.1,0.1)
📢注意:相机和灯光是无法缩放的,设置后是没有效果的

rotation旋转

与平移和缩放不同的是,旋转需要注意顺序问题,平移和缩放先设置哪个轴并不重要,但旋转的顺序不同其结果是不一样的;

  • 表示旋转的类:Euler类(欧拉角用来描述物体在三维欧几里得空间中的取向)
    • .position.scale一样,当我们创建一个新的场景对象时,会自动创建一个Euler实例并为其赋予默认值
const mesh = new Mesh();

mesh.rotation = new Euler();
mesh.rotation.set(2,2,2)
mesh.rotation.x = 3
  • 也可以单独创建Euler实例,并设置其值
import { Euler } from 'three';

const euler = new Euler(1, 2, 3);
  • 可以通过设置Eulerorder属性来改变旋转的顺序,默认是xyz

动画循环升级重构

一般通过renderer.render方法来绘制场景,此方法会将场景和相机作为输入,并将整个静止的图像输出到HTML中的canvas中,而为了让场景中的物体动起来,threeJS通过renderer.setAnimationLoop方法实现了静态到动图的转变;
另外threeJS中也提供了clockAPI,可以通过该API保持动画的同步,常规的动画循环是以每秒60帧(60FPS)的速率生成稳定的帧流,即需要每16毫秒执行调用一次render;

  • setAnimationLoop创建循环
    • 通过调用renderer.setAnimationLoop(() => {fn})来实现定频率的执行传入的fn逻辑
    • 可以通过传入null来作为回调函数取消正在运行的循环;renderer.setAnimationLoop(null)
    • 内部是通过使用.requestAnimationFrame,这种内置的浏览器方法可以智能的安排帧与显示器的刷新率同步,当硬件跟不上时,会平滑的降低帧率

为了解决不同对象有不同的动画更新逻辑,需要进行收集场景中的所有动画对象,为了避免大量对象收集导致该集合过于复杂,可以间接的将每个对应的对象进行单独收集需要循环的动画,通过对应的方法将这些依赖暴露出去,最后在最外层的入口处进行统一的触发更新逻辑(需要在该入口处获取到所有的需要更新的对象列表,然后循环列表的每个对象执行对应的收集依赖的方法进行处罚循环更新),这样也同时满足了应用程序设计时的模块化理念

  • clock在动画中的实践
    • 例如在旋转中,一般制定了制定的旋转角度进行渲染,但是这种方式会随着设备的硬件问题导致速率不一致的问题,即存在可变帧速率:在每个情况下,动画循环都会以较低的速率生成帧,且这个速率可能会因为许多因素从一个时刻到下一个时刻波动,因此需要将动画速度与帧速率解耦,可以通过告知一个对象执行动画前一帧时,根据前一帧花费的时间来动态的缩放移动/旋转的大小,这样随着帧率的变化,我们将不断的调整每个执行变化的大小,从而保证动画的流畅
    • 最终要实现的目标是:帧率可能会变,但是动画速度不会变,从而实现了将动画速度和帧率速率解耦
    • 在threeJS中可以通过MathUtils.degToRad来实现度数转弧度数的逻辑,然后将弧度数乘以前一帧花费的时间来实现动画速度与帧率解耦
    • 📢:在一般的场景中还是会不采用自动动画的逻辑,一般采用用户交互进行触发,只要用户不进行交互,场景将在帧之间保持不变

动画帧浅析

image.png

一般可以通过gsap动画库帮助我们做补间动画,即只需要定义开始和末尾的状态,这之间的过渡效果动画库会帮我们填充上,但是涉及到一系列的自定义动作时,就需要通过自定义帧去存储物体的运动数据;
一般大多数项目帧动画相关的数据都是3D美术在三维软件中设置好的,作为程序员来讲,直接加载三维模型然后加息模型中的帧动画包含的关键帧数据,然后通过后续的API来解析播放帧动画;
threeJS提供了一系列用户编辑和播放关键帧动画的API,使用关键帧keyframeTrack和剪辑AnimationClip编写一个关键帧动画,然后调用操作AnimationAction、混合器AnimationMixer播放编写好的关键帧动画

  • keyframeTrack关键帧轨道
    • 关键帧轨道keyframeTrack是关键帧keyframes的定时序列,它由时间和相关值的列表组成,用来让一个对象的某个特定的属性动起来;
    • keyframeTrack中总是存在两个数组:
      • times数组按顺序存储该轨道的所有关键帧的时间值
      • values数组包含动画属性相应的更改值
        • 值数组中可以是某一特定的时间点,不仅可以是一个简单的数字,还可以是一个向量(是位置动画)或者是一个四元数组(是旋转动画),因此值数组的长度可能是时间数组的三四倍
    • 实践相关
      • 构造函数:KeyframeTrack( name : String, times : Array, values : Array, interpolation : Constant )
        • name:关键帧轨道的标识符
        • times:关键帧的时间数组,被内部转化为Float32Array
        • values:与时间数组中的时间点相关的值组成的数组,被内部转化为Float32Array
        • interpolation:使用的插件类型
  • AnimationClip动画剪辑
    • 是一个可重用的关键帧轨道集,用来定义动画
    • 实践相关
      • 构造函数:AnimationClip( name : String, duration : Number, tracks : Array )
        • name:次剪辑的名称
        • duration:持续时间(/秒),传入负数时,持续时间将会从传入的数组中计算得到
        • tracks:一个由关键帧轨道组成的数组,AnimationClip里面,每个动画属性的数据都存储在一个单独的关键帧轨道中
  • AnimationMixer动画混合器
    • 动画混合器是用于场景中特定对象的动画播放器,当场景中的多个对象独立动画时,每个对象都可以使用同一个动画混合器
    • 实践相关
      • 构造函数:AnimationMixer( rootObject : Object3D )
        • rootObject:混合器播放的动画所属的对象

材质和纹理

在创建网格Mesh时,可以传入两个可选参数:几何体和材质,其中几何体定义了网格的形状,材料定义了网格的各种表面属性,特别是其对光的反应方式,在大多数情况下,使用材质表示事物要比几何图形更方便;

  • 纹理映射
    • 其意味着拿着图像并将其拉伸到3D对象的表面上,我们将这种方式使用的图像称为纹理

组Group对象

image.png 组的存在纯粹是为了帮助开发者操纵其他场景对象

组在场景中占据一个位置并且可以有子对象,但他们是不可见的,这样可以通过操作这个组来实现对组内的子对象的整体操作,当然,组内的子对象也是可以独立进行操作的,如旋转缩放等,正如一辆汽车是一个组,车的零部件是子对象,都可以整体或相对独立的运转;
threeJS中几乎所有的对象都有一个clone方法,其允许您创建该对象的相同副本。所有场景对象都继承自Object3D.clone,而几何体继承自BufferGeometry.clone,材质继承自Material.clone;需要注意的是在克隆某些对象时,并不是完全将内部对象全都克隆一遍,某些情况下是共享内部对象的,如克隆Mesh网格时,其中的几何体和材质不是克隆的,他们是共享的,除非单独再给克隆的对象新添一个全新的几何体或材质,否则将出现牵一发而动全身的情况;当然自定义属性也是不会被克隆的,需要在克隆的对象上再次设置该自定义的属性;

  • group中的动画问题
    • 可以通过上述的tick进行动画收集,然后在底层的 setAnimationLoop中进行循环执行,从而达到动画效果
    • setAnimationLoop在指定的60s时间间隔中会不断的循环执行 通过循环调用this.tick方法来达到不断执行场景对象中定义的tick动画逻辑
    • 可以通过将场景中的对象汇总到group中(包括组的动画和组内对象的动画进行收集),进行收集后,然后再底层的统一入口处进行触发;

3D模型加载与动画系统浅析

3D模型加载

在过去,FBX、 OBJ (Wavefront)和 DAE (Collada)格式的3D资源格式是最为常见的,虽然有各自的不足,如OBJ不支持动画、FBX是属于Autodesk的封闭格式,DEA规范过于复杂,导致大文件难以加载,但是最近新起的glTFGL传输格式称为了3D资源的最佳实践,其专门为在网络上共享模型而设计,因此文件大小尽可能小,且资源也将快速加载;
GLTF文件可以包括模型、动画、几何图形、材质、灯光、相机甚至是整个场景,这也就意味着可以在外部程序中创建整个场景,然后加载到threeJS中;

  • GLTFLoader插件
    • 和OrbitControls插件类似,GLTFLoader插件可以实现将GLTF文件加载到threeJS项目中,可以在examples/jsm/loaders/GLTFLoader.js中找到并引入该插件,然后通过new关键字实例化之后就可以进行加载对应资源了

    • threeJS加载器加载多个3D模型资源方式

      • 分别单独await加载
        • 多个await分别加载时会有时间成本
      // Don't do this!
      const parrotData = await loader.loadAsync('/assets/models/Parrot.glb');
      const flamingoData = await loader.loadAsync('/assets/models/Flamingo.glb');
      const storkData = await loader.loadAsync('/assets/models/Stork.glb');
      
      const parrot = setupModel(parrotData);
      const flamingo = setupModel(flamingoData);
      const stork = setupModel(storkData);
      
      • 通过Promise.all进行加载
      import { GLTFLoader } from 'https://cdn.skypack.dev/three@0.132.2/examples/jsm/loaders/GLTFLoader.js';
      import { setupModel } from './setupModel.js';
      async function loadBirds() {
        const loader = new GLTFLoader();
      
        // const parrotData = await loader.loadAsync('./src/assets/models/Parrot.glb');
        const [parrotData, flamingoData, storkData] = await Promise.all([
          loader.loadAsync('./src/assets/models/Parrot.glb'),
          loader.loadAsync('./src/assets/models/Flamingo.glb'),
          loader.loadAsync('./src/assets/models/Stork.glb'),
        ]);
        const parrot = setupModel(parrotData);
        parrot.position.set(0, 0, 2.5);
        const flamingo = setupModel(flamingoData);
        flamingo.position.set(7.5, 0, -10);
        const stork = setupModel(storkData);
        stork.position.set(0, -2.5, -10);
        // const storkDataC = Object.assign({}, storkData);
        // const storkCopy = setupModel(storkDataC);
        // storkCopy.position.set(0, 7.5, -10);
        // console.log(storkCopy, 'storkCopy=========');
      
        // console.log(parrotData, 'parrotData=========', parrot);
      
        return { parrot, flamingo, stork };
      }
      
      export { loadBirds };
      
      • 拓展:将控制器controls移动到目标对象中心方式
    • threeJS加载器的加载方式 - controls.target.copy

      import { createControls } from '../systems/controls.js';
      import { createRenderer } from '../systems/renderer.js';
      import { createCamera } from '../components/base/camera.js';
      class World {
          constructor(container) {
              camera = createCamera();
              renderer = createRenderer();
              ....
          }
      }
      async init(){
          const { parrot, flamingo, stork } = await loadBirds();
          controls.target.copy(parrot.position);
          loop.updatables.push(parrot, flamingo, stork);
          scene.add(parrot, flamingo, stork);
      }
      
      • 基于回调的.load方法
      • 基于Promise.loadAsync方法
      • 📢:在进行程序设计时,最好将设计拆分成同步和异步,这样可以使得我们完全控制引用程序的设置,在加载器中的体现就是在同步阶段我们将创建不依赖加载资源的所有内容(可以在constructor中直接编写),在异步阶段我们将创建所有依赖加载资源的内容(可以单独编写异步函数,然后在外部实例化后单独方法调用),然后在外部实例化使用时就可以通过async-await进行实现同步和异步分别加在了;另外在外部初始化时可以设置成async函数这样可以通过异步的方式调用加载器中的异步方法,也可以通过异步的方式进行错误的捕获和处理
      import { World } from './World/World.js';
      
      // 加async时为了可以调用异步的World.init方法  也可以通过异步的方式进行错误捕获和处理
      async function main() {
        const container = document.querySelector('#scene-container');
        const world = new World(container);
        await world.init(); //内部是通过`async-await定义的函数`
      
        world.start();
      }
      
      main().catch((err) => {
        console.log(err, 'err=========');
      });
      

动画系统

动画系统由许多组件组成,这些组件协同工作以创建动画、将其附加到场景中的对象并控制他们;具体可以分为两类动画创建动画播放和控制

动画创建

在threeJS中的动画系统中,一般不会直接纯手工制作动画,而是通过与Blender等外部软件中创建的动画一起使用,也可以直接在平台上直接下载已经做好的3D模型,动画涉及到三个元素:关键帧keyframeTrackAnimationClip

  • 关键帧
    • 动画系统中最底层的概念级别是关键帧,每个关键帧由三部分信息组成:时间time、属性Property和值value;分别描述了某个属性在特定时间的值
    • 关键帧没有指定任何特定的对象。位置关键帧可用于为任何具有.position属性的对象设置动画,缩放关键帧可以为任何具有.scale属性的对象设置动画
    • 创建动画时,至少需要两个关键帧,即起始和终止位置的状态
  • KeyframeTrack
    • 该基类下的每种数据类型的对应子类
    • 关键帧轨迹共同创建了动画,我们称为动画剪辑,因此可以理解为动画剪辑是附加到单个对象的任意数量的关键帧的合集,表示剪辑的类是AnimationClip
    • 动画剪辑存储三部分信息:剪辑的名称、剪辑的长度最后是组成剪辑的轨迹数据,一般情况下会将剪辑长度设置为-1,这样轨迹数组将用于计算长度
    import { NumberKeyframeTrack,VectorKeyframeTrack } from "three";
    const positionKF = new VectorKeyframeTrack(
      ".position",
      [0, 3, 6],
      [0, 0, 0, 2, 2, 2, 0, 0, 0]
    );
    // positionKF适用于任何具有`.position`属性的对象 与网格一起使用
    const times = [0, 1, 2, 3, 4];
    const values = [0, 1, 0, 1, 0];
    
    const opacityKF = new NumberKeyframeTrack(".material.opacity", times, values);
    //opacityKF适用于具有`.opacity`属性的材质上 与网格一起使用
    // just one track for now
    const tracks = [positionKF];
    // use -1 to automatically calculate
    // the length from the array of tracks
    const length = -1;
    
    const moveClip = new AnimationClip("slowmove", length, tracks);
    //此时AnimationClip还是没有与特定的对象进行绑定,可以通过后续的`AnimationAction`进行将剪辑与`具有相同内部结构`的任何其他模型一起使用
    
播放和控制

上述的「动画创建」中,创建了简单的动画剪辑,可以使得目标对象在指定的剪辑中进行相应的动画操作,而「播放和控制」则是需要将剪辑附加到一个对象上,然后播放,主要靠AnimationMixer(允许将静态对象转换为动画对象)和AnimationAction(将剪辑链接到对象并允许使用播放、暂停、循环、重置等操作来控制这些)

  • AnimationMixer混合器
    • 要使用动画系统为诸如网格之类的对象设置动画,需要将其链接到AnimationMixer混合器上,需要为场景中的每个动画对象使用一个混合器,混合器执行使得模型及时移动到动画剪辑的技术。
      import { Mesh, AnimationMixer } from 'three';
      
      // create a normal, static mesh
      const mesh = new Mesh();
      
      // turn it into an animated mesh by connecting it to a mixer
      const mixer = new AnimationMixer(mesh);
      
      box = new THREE.Mesh(
        new THREE.BoxGeometry(1,1,1),
        new THREE.MeshLambertMaterial({color:0x00ff00})
      )
      // 通过创建动画混合器实例,实现要做动画的物体与动画关联起来
      mixer = new THREE.AnimationMixer( box )
      // 通过动画混合器的clipAction方法,实现动画剪辑AnimationClip与动画混合器的关联
      const clipAction =  mixer.clipAction( moveClip )
      // 通过上面两步实现 box和clip的关联
      clipAction.play()
      
  • AnimationAction
    • 将动画对象链接到动画剪辑,该类也是暂停、播放、循环和重置等控件所在的位置
    • 该控件包含控件允许您混合两个剪辑、逐渐将剪辑减慢到停止、循环播放剪辑、反向播放或以不同的速度播放等等
    const mixer = new AnimationMixer(humanModel);
    
    const action = mixer.clipAction(moveClip);
    
    // immediately set the animation to play
    action.play();
    
    // later, you can stop the action
    action.stop();
    
    • 更新循环中的动画
    // 单独的clock更新
    const mixer = new AnimationMixer(mesh);
    const clock = new Clock();
    
    // you must do this every frame
    const delta = clock.getDelta();
    mixer.update(delta);
    
    // 推荐方法  通过loop收集指定对象来实现统一管理
    const mixer = new AnimationMixer(mesh);
    
    mesh.tick = (delta) => mixer.update(delta);
    
    loop.updatables.push(mesh);
    

拓展

threeJS三维模型的克隆和复制浅析🔗

image.png

  • 复制一个三维模型有两种方式
    • 一种是复制几何体对象
      • 本质上是复制的顶点数据
    • 另一种是复制网格模型
      • 本质上是多次调用threeJS渲染方法render,或者说多次调用WebGL的绘制函数引用同一组顶点数据,在GPU显存中生成多个几何体的像素值,最终显示在屏幕上,这里可以看出网格模型的复制并不是真正复制了数据,只是利用程序实现了两个三维模型的显示效果
  • 几何体的克隆 → .clone()
    • 主要是利用了实例化后的几何体的.clone()方法进行克隆,本质类似于深拷贝,克隆后的几何体与原几何体互不影响
  • 几何体的复制 → .copy()
    • 几何体间的复制是利用了.copy()方法,主要是将目标对象的顶点数据替换当前几何体的顶点相关数据,也是深拷贝
  • 网格模型的克隆 → .clone()
    • 两个网格模型共用一个几何体对象的顶点相关数据,可以理解为网格内部的对象是浅克隆,会相互影响

实例化threeJS项目步骤浅析

可以将常规的threeJS项目进行拆分实现颗粒化,如createScene()(初始化场景)、createCamera()(初始化相机)、createAxesHelper(初始化辅助轴)()、createLights()(初始化灯光)、createRenderer()(初始化渲染器)、animate()(循环执行)、initControl()(初始化轨道控制器)、initLoop()(初始化动画系统)

createScene → 初始化场景

import { Color, Scene } from 'https://cdn.skypack.dev/three@0.132.2';

function createScene() {
  const scene = new Scene();
  scene.background = new Color('skyblue');
  return scene;
}

export { createScene };

createCamera → 初始化相机

import { PerspectiveCamera } from 'https://cdn.skypack.dev/three@0.132.2';

const container = document.querySelector('#scene-container');
const fov = 35; // fov = Field Of View
const aspeect = container.clientWidth / container.clientHeight; // aspect ratio (dummy value)
const near = 0.1; // near clipping plane
const far = 100; // far clipping plane

function createCamera() {
  const camera = new PerspectiveCamera(
    fov, // fov = Field Of View
    aspeect, // aspect ratio (dummy value)
    near, // near clipping plane
    far // far clipping plane
  );

  camera.position.set(0, 0, 10);

  return camera;
}

export { createCamera };

createAxesHelper

function initAxesHelper() {
  axesHelper = new THREE.AxesHelper(3)
  scene.add(axesHelper)
}

createLights → 初始化灯光

import {
  DirectionalLight,
  AmbientLight,
} from 'https://cdn.skypack.dev/three@0.132.2';

function createLights() {
  const light = new DirectionalLight('white', 8);
  const ambientLight = new AmbientLight('white', 2);
  light.position.set(10, 10, 10);
  return { light, ambientLight };
}

export { createLights };

createRenderer → 初始化渲染器

// 渲染器系统
import { WebGLRenderer } from 'https://cdn.skypack.dev/three@0.132.2';

function createRenderer() {
  const renderer = new WebGLRenderer({
    antialias: true, // 启用抗锯齿
  });
  renderer.physicallyCorrectLights = true; // 启用物理颜色矫正
  return renderer;
}

export { createRenderer };

createControls → 初始化轨道控制器

import { OrbitControls } from 'https://cdn.skypack.dev/three@0.132.2/examples/jsm/controls/OrbitControls.js';

function createControls(camera, canvas) {
  const controls = new OrbitControls(camera, canvas);
  controls.enableDamping = true; // 启用阻尼以增加真实感

  controls.tick = () => controls.update();
  controls.autoRotate = true;
  controls.autoRotateSpeed = 10;
  return controls;
}

export { createControls };

initLoop → 初始化动画系统

// 用于处理动画系统和循环逻辑  目的是保持动画同步

import { Clock } from 'https://cdn.skypack.dev/three@0.132.2';

const clock = new Clock();
class initLoop {
  constructor(camera, scene, renderer) {
    this.camera = camera;
    this.scene = scene;
    this.renderer = renderer;
    this.updatables = [];
  }

  start() {
    this.renderer.setAnimationLoop(() => {
      this.renderer.render(this.scene, this.camera);
      // setAnimationLoop在指定的60s时间间隔中会不断的循环执行 通过循环调用this.tick方法来达到不断执行场景对象中定义的tick动画逻辑
      this.tick(); // 通知所有动画进行更新
    });
  }

  stop() {
    this.renderer.setAnimationLoop(null);
  }

  tick() {
    const delta = clock.getDelta();
    for (const object of this.updatables) {
      object.tick(delta);
    }
  }
}

export { initLoop };