这一次不再只旋转模型,而是让智能音箱自己完成一次装配:底座先归位,网罩和机壳随后从不同空间方向回到中心,灯环与顶盖最后合上。当前正在运动的零件会被薄荷色高亮,拖动时间线可以停在任意装配阶段,再上下左右环绕检查空间关系。

暂停在 43% 时,画面保留机壳、网罩和底座,顶盖与灯环仍在待装配位置;点击播放后继续向 100% 归位。拖动时间线到中段会记录 SCENE_ANIM_SEEK progress=0.490000,节点立即跳到对应装配状态。

先看装配过程
页面把结构模型的五个节点映射到一条时间轴。播放时,各节点按照"底座、网罩、机壳、灯环、顶盖"的顺序使用平滑插值回到原位;当前阶段节点通过 Geometry.mesh.materialOverride 套用薄荷色材质,读者可以直接判断哪一件正在装配。暂停不会清除当前姿态,重播会从0%重新开始。
这段动画真正展示的是三个 3D 能力:第一,零件不是贴在画面上的平面,而是带有 X/Y/Z 坐标的独立空间节点;第二,相机可以在装配过程中上下左右环绕,观察零件之间的前后遮挡和间隙;第三,材质覆盖会随当前阶段改变,让正在装配的零件从整机中被识别出来。把时间线停在 42% 时,可以看到机壳已经靠近中心,底座和网罩仍然保持分层,灯环与顶盖还在上方等待归位。
如果只看完整态,所有零件会重新重合,动画价值很难被看出来。阅读这篇文章时建议先看 GIF 的爆炸态,再看暂停截图,最后拖到 100% 对比完整态。
一、节点动画怎么来的
案例复用前文的本地 orbitdesk_structure.glb,只改变节点的 position,不复制几何体和材质。每个零件保存一条初始偏移量:
const partNames: string[] = [
'MetalBaseLower', 'SpeakerGrille', 'SpeakerBody',
'TopRing', 'TopInset'
];
const xOffsets: number[] = [-0.48, 0.58, -0.42, 0.64, -0.52];
const yOffsets: number[] = [-1.15, -0.55, 0, 0.55, 1.10];
const zOffsets: number[] = [0.42, -0.26, 0.34, -0.32, 0.26];
const nodes: Node[] = [];
const baseX: number[] = [];
const baseY: number[] = [];
const baseZ: number[] = [];
for (const name of partNames) {
const node = find(scene.root, name);
if (node) {
nodes.push(node);
baseX.push(node.position.x);
baseY.push(node.position.y);
baseZ.push(node.position.z);
}
}
baseX/baseY/baseZ 是装配完成后的坐标,三组 offset 是爆炸状态的空间偏移。这样同一个节点既能被拆开,也能准确回到原来的位置。网罩不是一张 2D 图片,五个部件都是真实 GLB 节点。
const phase = Math.max(0, Math.min(1, progress * 1.32 - index * 0.11));
const eased = phase * phase * (3 - 2 * phase);
node.position = {
x: baseX[index] + xOffsets[index] * (1 - eased),
y: baseY[index] + yOffsets[index] * (1 - eased),
z: baseZ[index] + zOffsets[index] * (1 - eased)
};
progress 从0到1变化,offset 决定节点从哪里开始,smoothstep 让开始和结束都更柔和。返工后的每个零件同时拥有 X/Y/Z 偏移,例如网罩从侧后方回到中心,顶盖从上方回位。动画每一帧都根据初始状态重新计算,不累加位移,因此拖动时间线后不会产生漂移。
3. 用材质覆盖告诉读者当前装到哪一件
只移动零件仍然容易看漏当前阶段,所以页面为当前节点临时套用一套薄荷色材质:
if (node.nodeType === NodeType.GEOMETRY) {
const geometry = node as Geometry;
geometry.mesh.materialOverride = index === currentPart
? assemblyHighlight
: undefined;
}
这个覆盖只改变当前节点的显示材质,GLB 原本的网孔、灯环和金属材质仍然保留。动画播放时,标题、阶段文字和模型高亮指向同一个零件。
4. 相机也参与动画观察
手指拖动改变的是相机球面位置,而不是整体旋转模型:
const yaw = this.yaw * Math.PI / 180;
const pitch = this.pitch * Math.PI / 180;
const radius = 7.8;
camera.position = {
x: radius * Math.cos(pitch) * Math.sin(yaw),
y: 0.55 + radius * Math.sin(pitch),
z: radius * Math.cos(pitch) * Math.cos(yaw)
};
因此可以从正面、侧面和俯视角度观察零件之间的前后关系;模型本身不需要被整体旋转,也不会因为父节点旋转造成网格错位。
二、播放、暂停与时间线
播放按钮只切换 playing 状态,定时器根据时间差推进进度;暂停时保留进度和节点位置。速度按钮改变推进系数,时间线改变进度并立即调用同一套插值函数。
5. 播放循环只负责推进时间
if (this.playing) {
this.progress = Math.min(1, this.progress + dt * this.speed / 4);
this.apply();
}
在实际页面中,dt 来自两次定时器回调之间的秒数,避免把设备刷新频率写死。apply() 负责把当前进度转换成五个节点的新坐标;因此播放、拖动和重播共享同一条空间计算路径。
6. 时间线拖动是一次可回看的空间取样
Slider({ value: this.progress, min: 0, max: 1, step: 0.01 })
.onChange((value: number, mode: SliderChangeMode) => {
this.playing = false;
this.progress = value;
this.current = Math.min(4, Math.floor(value * 5));
this.apply();
if (mode === SliderChangeMode.End) {
hilog.info(0, 'AssemblyAnimation',
'SCENE_ANIM_SEEK progress=%{public}d', value);
}
});
例如拖到 49%,模型会停在机壳阶段;此时相机仍然可以环绕,读者能从侧面看出网罩和机壳的前后间距。它不是把 GIF 快进到某一帧,而是重新计算真实节点的三维姿态。
在手机上可以看到五个零件从分离状态逐步回到装配位置;暂停后当前空间关系保持不变,恢复播放才继续移动。实际运行还覆盖了时间线跳到 49% 的中段状态和退出释放。GIF来自真实设备页面采帧,用来展示状态变化,不代表渲染帧率。
三、为什么要单独处理退出
页面退出时先停止定时器,再释放场景引用;加载代次递增后,尚未完成的旧加载结果会被销毁。这样快速返回首页、重复进入和重复释放都不会把旧场景带回页面。
this.generation++;
this.playing = false;
if (this.timer !== -1) {
clearInterval(this.timer);
this.timer = -1;
}
this.scene?.destroy();
this.scene = undefined;
四、复现步骤
从"ArkGraphics 3D 实验空间"进入"装配动画实验室"。先点击"重播"回到爆炸态,再点击"播放";当机壳高亮、标题显示约 40% 时点击"暂停",观察零件之间的空间间隙。随后点击"播放"继续完成装配,拖动时间线可以直接跳到任意阶段,点击"1.5x"可以加快预览。案例使用本地模型,不需要网络和相机权限。
你可以按"爆炸态 → 中段暂停 → 完整态"的顺序阅读结果:爆炸态用于看清零件层级,中段用于看变换和高亮,完整态用于确认所有节点回到同一套装配坐标。