Anime.js 4.5 接入 Three.js:从半秒钟的动画查清属性映射与渲染

用实测的 Anime.js 4.5.0 与 Three.js 0.180.0 示例检查位移、角度和透明度,解释中间帧、渲染循环、材质共享、资源清理与页面排障。

把应用部署到土耳其|BRNCHOST · 土耳其 VDS
云服务器,积分可续期|雨云 · 国内外节点 · 积分兑换权益
低价年付,大流量 VPS|RackNerd · SSD 存储 · 1Gbps 端口
香港轻量,搭个小站|晚安云 · 香港云服务器
香港 VPS,大带宽可选|野草云 · BGP 直连
大陆访问,精品线路|搬瓦工 · CN2 GIA / CTGNet 套餐
资料归档,交给 AI 整理|WorkBuddy · 本地文件处理
建站起步,先看应用镜像|腾讯云 · 轻量应用服务器
CN2 GIA,中国方向优化|DMIT · Premium 网络
双 ISP 原生住宅 IP|丽萨主机 · 美国 9929 精品线路
高频 CPU,多地部署|Evoxt · 云服务器 · 每周异地备份
京东云轻量云主机:129元/年,新人专享,限购1台

给网页里的模型做一次位移、转身和淡出,通常会同时修改位置、旋转与材质。Anime.js 4.5 的 Three.js 适配器让这些变化可以写在同一次动画调用里。第一次接入时,先把动画停在中间帧,检查对象的真实数值,再看画面,能少绕许多弯路。

适配器究竟替你处理了什么

在 Three.js 中,网格的位置保存在 mesh.position,旋转保存在 mesh.rotation,透明度则属于材质。Anime.js 的适配器将常用动画名称映射到这些嵌套属性,例如 x 对应位置的横轴,rotateY 对应绕 Y 轴旋转。旋转简写接受度数,适配器负责换算为 Three.js 使用的弧度。加载方式与完整支持范围见官方适配器文档。

这意味着 rotateY: 180 与直接设置 mesh.rotation.y = 180 的含义不同。前者表示半圈,后者是把一百八十弧度写入对象。接手旧动画时,要先看数值写在哪一层,再决定是否转换。把原来写给 rotation 的弧度值原样搬到 rotateY,画面可能只转了一点点。

适配器没有替你建立场景、加载模型或运行渲染器。即使属性已经变化,如果页面不再执行渲染,画面仍会停在上一帧。排查时把“数值是否正确”和“这一帧有没有画出来”分开,问题就容易定位。

先用固定版本做一个小页面

本文在 2026 年 9 月 23 日验证了 Anime.js 4.5.0 与 Three.js 0.180.0 的组合。数值测试使用 Node.js 25.5.0,并在 Chromium 中加载同一组依赖验证了画面和中间帧。这个结果证明本文示例能运行,不代表所有模型、手机或显卡都经过兼容性测试。

在一个已有模块打包器的独立练习项目中,把下面两个依赖版本加入现有 package.json 的 dependencies,再运行 npm install。保留项目原来的其他字段,不要用这个片段覆盖整个文件。

{
  "dependencies": {
    "animejs": "4.5.0",
    "three": "0.180.0"
  }
}
npm install

页面可以只放一个模块入口,并把 body 的 margin 设为零、canvas 设为块元素。下面的代码会把画布添加到 body。它不需要外部模型和纹理,使用线框材质也能避开“场景没灯,所以看不见物体”的干扰。不要把它直接粘进服务器执行的文件;创建 WebGLRenderer 的部分需要浏览器环境。

import { animate } from 'animejs';
import * as THREE from 'three';
import 'animejs/adapters/three';

const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.body.appendChild(renderer.domElement);

const scene = new THREE.Scene();
scene.background = new THREE.Color('#eef2f6');
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(0, 0, 7);

const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshBasicMaterial({
  color: '#2962ff',
  wireframe: true,
  transparent: true,
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);

const motion = animate(mesh, {
  x: 2,
  rotateY: 180,
  opacity: 0.4,
  duration: 1000,
  ease: 'linear',
  autoplay: false,
});

function resize() {
  renderer.setSize(window.innerWidth, window.innerHeight);
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
}
window.addEventListener('resize', resize);
resize();
renderer.setAnimationLoop(() => renderer.render(scene, camera));
motion.seek(500);

这里有意关闭自动播放,把一秒钟的动画停在第五百毫秒。你应该看到位移后的线框立方体。需要观察连续运动时,把最后一行改成先 motion.seek(0),再 motion.play()。练习阶段不要同时引入滚动驱动、鼠标跟随和循环,否则很难知道哪个逻辑最后修改了对象。

中间帧比“看起来动了”更容易验收

本例使用线性缓动,初始 x 为零、旋转为零、透明度为一。因此动画走到一半时,预期位置是 x 等于一,旋转为 π/2,透明度为零点七。结束时则是 x 等于二、旋转为 π、透明度为零点四。

实际验证得到的结果如下。它们是本例对象上的属性值,不是根据截图猜出来的。

  • 起点为 0 毫秒:position.x 为 0,rotation.y 为 0,opacity 为 1。
  • 到 500 毫秒:position.x 为 1,rotation.y 为 1.5707963267948966 弧度,opacity 为 0.7。
  • 到 1000 毫秒:position.x 为 2,rotation.y 为 3.141592653589793 弧度,opacity 为 0.4。
  • 再次回到 0 毫秒:三项分别恢复为 0、0、1。

如果换成非线性缓动,中间时刻不一定对应数值的一半,所以不要直接沿用这组数值。可以先保留线性版本确认映射,再调整缓动曲线。循环、反向播放和多个片段叠加,也应该在单段动画正确以后逐项加入。

对复杂模型来说,视觉判断还有别的误差来源。物体本身可能左右对称,转半圈看上去与原来相似;父节点可能已经旋转或缩放;模型原点可能不在视觉中心。检查局部 position 和 rotation 后,再看父级变换,通常比不停改动画参数更有效。

两个物体同时变透明,先检查材质共享

复制网格不一定复制材质。如果两个网格引用同一个材质对象,你通过其中一个网格改变 opacity,另一个也可能受到影响。需要独立淡出时,为该对象创建独立材质,或在明确管理共享关系的情况下克隆材质。反过来,如果一组对象本就应该同步变化,共享材质也可以减少重复对象。

透明度还涉及渲染方式。示例提前开启 transparent,只是让淡出有基本的正确条件。多个半透明物体交叠时,仍可能出现排序和深度相关问题;不能把这些视觉问题全归给动画库。先去掉动画,手工设置一个固定透明度,看相同视角下是否仍然出错,再决定排查材质还是时间线。

资源的生命周期也要随着对象关系安排。页面离开时应暂停动画、停止渲染循环、移除监听器,并释放自己拥有的几何体、材质与渲染器。共享资源要在最后一个使用者退出以后再释放。Three.js 的资源清理说明解释了这些对象为什么需要显式处理。

不要让两个更新循环争着写同一个值

假设原来每帧都执行 mesh.rotation.y += delta,后来又加了 Anime.js 的 rotateY 动画。两段逻辑会在各自更新时写入旋转值,结果可能抖动、忽快忽慢,或者一暂停就跳回另一个位置。此时即使适配器映射正确,画面也不会按单一时间线运行。

做滚动动画、拖拽旋转或相机控制时,为每个属性选一个明确的写入者。例如产品展示的开场由时间线控制,用户开始拖拽后暂停相关动画,再交给交互控制器。若确实需要叠加效果,可以分别作用于父节点和子节点,但要写清两层变换的职责,防止后来维护的人又在同一层新增写入。

React 等框架中还要避免每次渲染都重新创建场景和动画。开发模式的重复挂载也能暴露清理遗漏:离开再返回页面以后,如果立方体越转越快或画布出现多份,优先检查初始化和销毁,而不是调整 duration。

页面空白时,从画布到对象逐层排查

调试时保留控制台中的第一条异常。后续报错可能只是初始化失败的连带结果,逐条修改末尾警告容易偏离原因。先在开发者工具里确认 canvas 存在,而且宽高不是零。示例使用整个窗口的尺寸,嵌入卡片后则应改为容器的实际大小。如果容器在折叠面板里,初始化时读到的尺寸可能为零;展开后需要重新测量。只监听窗口 resize,不一定能捕获侧栏展开或父容器布局变化,实际应用可以按布局机制补充尺寸观察。

接着暂停动画,把物体放回原点,确认相机朝向和可见范围。若这一帧能显示,再恢复位移。移动距离超出视野并不是动画没有执行,查看 position 就能区分。使用真实模型时,还应确认加载结束后才创建针对模型的动画,避免把空引用或临时占位对象交给 animate。

如果画面能够显示但对象属性没有变化,检查适配器导入是否存在,再确认依赖解析是否一致。只安装 animejs 和 three,并不等于已经执行了副作用导入。打包后出现与开发环境不同的结果时,查看最终模块依赖,确认没有误删这个入口,也没有混用多份不一致的 Three.js 对象类型。

嵌入页面的最后一个检查是离开再返回。连续重复几次,观察画布数量、监听器和动画是否积累。清理函数里不要只移除 DOM:后台仍在运行的循环可能继续持有场景和材质。相反,如果把共享资源过早释放,其他组件又会出现空白或重新编译的开销。资源归属应随组件设计一起确定,不能只在报错后补一个统一的 dispose。

从一个物体扩展到阵列

4.5.0 发布说明还介绍了三维 stagger、随机偏移和可复现的 seed。它们适合让一组对象按空间关系依次运动。固定种子对截图比较和回归检查有用,因为每次运行都能讨论同一组顺序;上线后是否换种子,是产品效果的选择。

实例化网格需要额外注意。一个 InstancedMesh 在场景里是一个对象,并不意味着你对它调用一次动画就能独立控制每个实例。应按适配器文档取得实例代理,再安排各自的动画。先用少量对象验证顺序和中心位置,再增加数量,避免在大阵列里同时排查索引、坐标和性能。

本例把像素比上限设为二,是练习页面的一个选择,不是移动设备性能保证。真实项目还需要观察画布尺寸、对象数量、透明重叠和纹理开销。对偏好减少动态效果的用户,可以直接展示稳定终态或缩短非必要运动;数值正确以后,仍要在目标手机上检查交互是否可用、文字是否可读。

第一次接入做到这里,应该已经能解释每一项变化写进了哪个属性、怎样检查中间帧,以及页面离开后如何停止。以后再加入真实模型和复杂编排时,保留这份小例子作为对照,遇到异常就能判断是基础接入出错,还是新场景引入了额外条件。