【共创稿事节】HarmonyOS ArkGraphics 3D 实操——给智能音箱做一台结构探索台

点击"开始拆解",音箱沿着一条装配轴逐件展开:顶盖面、灯环、机壳、状态灯、网罩壳、孔壁、上底座和下底座各自离开原位。进度到 100% 后,选择任意细件即可自动拉近镜头;拖动模型还能从顶部、底部和侧面检查它的形状。

这是一个可以直接运行的 ArkGraphics 3D 教学案例。模型资源随应用打包,页面在 HUAWEI Mate 60 Pro 上运行,拆解、组装、进度拖动、细件高亮、近距离查看和相机观察都由同一个本地 GLB 场景驱动。

先看五个交互效果

完整形态点击一次进入拆解,再点击一次即可重新组装。动画持续约 2.4 秒,部件之间留出的距离让装配关系一眼可见。

拖动进度条可以停在任何位置。47% 时,顶盖和灯环已经分开,机壳与网罩仍保持相近位置;继续拖动到 100%,八个细件完全展开。

在完全展开状态点击"孔壁",页面会替换孔几何的材质,自动进入近距离查看,并显示"1248 个几何孔围绕机身排列"的说明。按钮高亮只作用于当前选中的细件。

点击"重新组装"可以沿相同路径回到完整形态,插值方向反转,不需要为回程再写一套坐标。

拆解完成后,点击"细看所选"可以在全景和近景之间切换;在近景里开启"旋转部件",拖动手势会直接转动当前细件,适合检查孔壁深度、灯环截面和底座倒角。下面这段真机动图先把孔壁移到侧边观察位,再旋转细件,随后切回全景并查看下底座。

拆完以后,逐件近距离检查

100% 展开后,八个按钮分别对应一个真实 GLB 节点。点"孔壁"会把孔壁沿 X 轴移到侧边观察位,镜头目标也跟随它;点"下底座"则把底座移到同一观察位,其他部件仍留在爆炸分解位置,方便比较装配间隙。近景状态下,默认拖动模型改变相机的水平环绕和上下俯仰;开启"旋转部件"后,拖动会直接改变当前节点的 yaw/pitch。查看完点击"返回全景"即可回到完整拆解视图。

这里的内部结构是教学示意模型,用来讲解节点组织和空间变换,不代表某一款真实音箱的拆机结构。

八个零件,八段独立动图

下面每段动图只保留一个零件:先把它移到侧边观察位,再打开"旋转部件",依次记录水平旋转和上下俯仰。读者可以直接从动图看到每个节点的轮廓、厚度与细节变化。

顶盖面:看见台阶和内侧凹槽
灯环:观察环体截面和发光边缘
机壳:查看空心圆筒的上下开口
状态灯:转动透镜和底部短圆柱
网罩壳:检查外轮廓和边缘倒角
孔壁:从不同方向看 1248 个孔的深度
上底座:观察机壳与底盘的过渡面
下底座:查看底部圆角承托面

一、先把模型拆成可以管理的节点

模型生成脚本是 tools/generate-orbitdesk-glb.js。为了不影响前两篇文章使用的资源,结构探索台输出独立文件:

复制代码
node tools/generate-orbitdesk-glb.js --structure

命令会生成 entry/src/main/resources/rawfile/orbitdesk_structure.glb。脚本先把顶点、法线和索引写入二进制 Buffer,再组装 glTF 2.0 的 JSON 与 BIN 区块,最后写成 GLB。每个几何体都通过 addMesh 注册成一个独立 mesh,材质索引写进 primitive。

1. 机壳:两层半径形成空心圆筒

结构页的机壳不是实心圆柱,而是同时生成外壁和内壁的环形墙体。外半径 0.80、内半径 0.745、高度 0.88,足够在拆开后看到开口和壁厚:

复制代码
const bodyMesh = addMesh(
  'SpeakerBodyMesh',
  ring(0.80, 0.745, 0.88, 128),
  0
);

ring 函数为每个角度段写入外壁和内壁顶点,再为上下边缘补齐三角形。外壁法线朝外,内壁法线朝内,光照计算才能在开口处保持正确。

2. 网罩:96 列 × 13 行的几何孔

网罩由 perforatedGrille 生成,而不是把一张带圆点的图片贴到圆柱上。每个孔包含外缘、孔口和内壁三层顶点,内半径比外壁缩进 0.018,因而从斜角看能看到孔的深度。

复制代码
function perforatedGrille() {
  const columns = 96;
  const rows = 13;
  const radius = 0.808;
  // 每个单元写入八边形外缘、孔口和凹入的内缘。
  // 96 * 13 = 1248 个孔,全部绕圆柱一周。
}

const grilleMesh = addMesh(
  'PerforatedGrilleMesh',
  perforatedGrille(),
  1
);

脚本还保留一层深色内衬 SpeakerGrilleMesh。页面把网罩壳和孔壁拆成两个可选节点,拆开时仍能看到孔洞后方的深色层,不会因为放大查看而透空。

3. 顶盖、灯环和底座

顶盖使用带厚度的圆柱,灯环使用环面,底座用旋转曲面描述圆角轮廓:

复制代码
const topMesh = addMesh('TopInsetMesh', detailedStructureTop(), 1);
const topRingMesh = addMesh('TopRingMesh', torus(0.71, 0.048, 128, 16), 3);
const baseLowerMesh = addMesh('BaseLowerMesh', lathe([
  [-0.08, 0], [-0.079, 0.98], [-0.065, 1.035],
  [-0.035, 1.06], [0.025, 1.06], [0.055, 1.035],
  [0.075, 0.98], [0.076, 0]
], 128), 2);

lathe 接收一组 [高度, 半径] 轮廓点,绕 Y 轴复制 128 段。增加靠近边缘的轮廓点,就能让底座从硬直圆柱变成有倒角的金属底座。

为了让近景不只看到一块平面,--structure 模式还对三个容易被放大的部位补了细节:顶盖用旋转曲面做出边缘台阶,并叠加一圈浅凹槽;网罩壳用六个轮廓点形成上下倒角;状态灯由带圆角的透镜和短圆柱底座合并而成。它们仍各自属于原来的 TopInsetSpeakerGrilleStatusLamp 节点,选择、侧置和旋转时不会拆散成无法管理的碎片。

复制代码
const topMesh = addMesh('TopInsetMesh', detailedStructureTop(), 1);
const grilleMesh = addMesh('SpeakerGrilleMesh', detailedStructureGrille(), 5);
const lampMesh = addMesh('StatusLampMesh', detailedStatusLamp(), 4);

二、用节点名表达装配关系

GLB 的根节点叫 OrbitDeskProduct,页面按功能把子节点拆成八个细件。每个按钮只对应一个节点,所以近景查看时不会把相邻部件一起放大:

序号 节点 页面名称 拆解方向
0 TopInset 顶盖面 上移 1.65
1 TopRing 灯环 上移 1.25
2 SpeakerBody 机壳 上移 0.82
3 StatusLamp 状态灯 上移 0.42
4 SpeakerGrille 网罩壳 下移 0.08
5 GrilleDots 孔壁 下移 0.52
6 MetalBaseUpper 上底座 下移 0.98
7 MetalBaseLower 下底座 下移 1.36

网罩壳、孔壁和上下底座都使用独立节点,进度插值时分别计算位置。这样既保留 GLB 的结构清晰度,也能让用户在拆解完成后逐件检查。

进入近景时,当前节点的 X 坐标会从原位移到 2.80 左右,Z 坐标只做很小的分层偏移;相机以这个新坐标为目标,所以画面中看到的是"侧置后的单件",不是把整机放大后裁掉其他部件。

三、拆解动画只计算"初始位置 + 偏移量"

页面加载后保存每个节点的初始坐标:

复制代码
class StructurePart {
  node: Node;
  group: number;
  x: number;
  y: number;
  z: number;
  rotationX: number;
  rotationY: number;
  rotationZ: number;
  rotationW: number;
  constructor(node: Node, group: number) {
    this.node = node;
    this.group = group;
    this.x = node.position.x;
    this.y = node.position.y;
    this.z = node.position.z;
    this.rotationX = node.rotation.x;
    this.rotationY = node.rotation.y;
    this.rotationZ = node.rotation.z;
    this.rotationW = node.rotation.w;
  }
}

每次更新都从这组初始值计算,避免在定时器中不断叠加位移导致误差:

复制代码
const offsets: number[] = [1.65, 1.25, 0.82, 0.42, -0.08, -0.52, -0.98, -1.36];
const k = this.progress * this.progress * (3 - 2 * this.progress);
const focus = this.inspecting && this.selected >= 0;
this.parts.forEach((part: StructurePart) => {
  const isFocus = focus && part.group === this.selected;
  part.node.position = {
    x: part.x + (isFocus ? 2.8 : 0),
    y: part.y + offsets[part.group] * k,
    z: part.z + (isFocus ? (part.group - 3.5) * 0.06 : 0)
  };
  if (!isFocus) {
    part.node.rotation = {
      x: part.rotationX, y: part.rotationY,
      z: part.rotationZ, w: part.rotationW
    };
  }
});

k 是 smoothstep 曲线,起点和终点速度为零,中间段运动更自然。进度条和自动动画都只修改 progress,所以拖动到 37% 和动画运行到 37% 会得到完全相同的空间状态。

四、进度条和按钮共用一套状态

"开始拆解"按钮把目标设为 1,"重新组装"把目标设为 0,40ms 定时器按真实经过的时间推进:

复制代码
this.target = this.progress > 0.99 ? 0 : 1;
this.running = true;

const dt = Math.min(0.1, (now - this.lastTick) / 1000);
this.progress = this.target === 1
  ? Math.min(1, this.progress + dt / 2.4)
  : Math.max(0, this.progress - dt / 2.4);
this.apply();

Slider 的 onChange 直接写入 progress 并调用同一个 apply 方法,松手时记录当前位置。用户可以先用按钮播放,再用滑杆接管,不需要同步两套动画变量。

五、部件高亮使用材质覆盖

页面只创建一份青绿色高亮材质。选择按钮根据组号找到节点,把它们的 materialOverride 指向这份材质;再次点击则清除覆盖:

复制代码
this.parts.forEach((part: StructurePart) => {
  if (part.node.nodeType === NodeType.GEOMETRY) {
    (part.node as Geometry).mesh.materialOverride =
      part.group === this.selected ? this.highlight : undefined;
  }
});

这里的 group 是每个细件的唯一组号,不再把网罩壳、孔壁或两层底座合并成一个选择项。高亮是观察辅助,网罩的孔数量、内壁和内衬仍来自原始几何体。页面采用部件按钮作为选择入口,避免把未验证的屏幕拾取接口写进案例。

六、相机可以上下左右观察

结构页沿用灯效页的球面相机。水平拖动改变 yaw,垂直拖动改变 pitch,并把俯仰限制在 -65° 到 65°:

复制代码
this.yaw = this.startYaw + event.offsetX * 0.5;
this.pitch = Math.max(
  -65,
  Math.min(65, this.startPitch - event.offsetY * 0.32)
);
this.pose();

拆解进度越大,相机距离越远,保证顶盖上移和底座下移后仍在画面中。进度到 100% 后选中细件,点击"细看所选"会把距离缩短到 4.6,并把目标点移到该节点的实际高度;点击"返回全景"恢复 13 的全景距离。相机使用不透明 clearColor,连续旋转时背景不会留下旧轮廓。

近景切换只改变相机目标和半径,不改动节点坐标,因此观察结束后不会把拆解状态带偏:

复制代码
const focus = this.inspecting &&
  this.selected >= 0 && this.selected < this.parts.length;
const radius = focus ? 4.6 : 7.8 + k * 5.2;
const targetX = focus ? this.parts[this.selected].node.position.x : 0;
const targetY = focus ? this.parts[this.selected].node.position.y : 0.35;
const targetZ = focus ? this.parts[this.selected].node.position.z : 0;
camera.position = {
  x: targetX + Math.sin(yaw) * radius,
  y: targetY + radius * Math.sin(pitch),
  z: targetZ + Math.cos(yaw) * radius
};

近景默认是"相机观察"模式。如果需要把单件本身转起来,点击"旋转部件"后复用同一个拖动层,横向位移写入 partYaw,纵向位移写入 partPitch,并把结果写回选中节点;按钮显示"停止旋转"时,拖动不会改变相机位置。

复制代码
if (this.inspecting && this.rotatingPart && this.selected >= 0) {
  this.partYaw = this.startPartYaw + event.offsetX * 0.5;
  this.partPitch = Math.max(-45,
    Math.min(45, this.startPartPitch - event.offsetY * 0.32));
  this.apply();
}

七、加载和退出要成对处理

页面加载 GLB、环境、相机、方向光和高亮材质。generation 代次值用于处理"加载过程中返回"的情况:如果异步加载完成时页面已经退出,旧 Scene 和材质立即销毁,不再启动定时器。

退出页面时停止定时器,销毁动态材质和 Scene:

复制代码
private release(): void {
  this.generation++;
  this.running = false;
  if (this.timer !== -1) {
    clearInterval(this.timer);
    this.timer = -1;
  }
  this.highlight?.destroy();
  this.scene?.destroy();
  this.highlight = undefined;
  this.scene = undefined;
}

八、真机结果

Mate 60 Pro 的实际链路是:场景加载完成后记录 STRUCTURE_READY parts=8 groups=8,点击"开始拆解"到达 STRUCTURE_FINISH progress=1;随后选择第 6 个细件"孔壁",日志记录 STRUCTURE_SELECT group=5STRUCTURE_INSPECT part=孔壁 radius=4.600000STRUCTURE_FOCUS part=孔壁 x=2.80 z=0.09,证明节点已经移到侧边观察位。开启"旋转部件"并拖动后记录 STRUCTURE_PART_ROTATE yaw=68.054297 pitch=-18.302262,随后点击"返回全景"记录 STRUCTURE_INSPECT part=全景 radius=13;再选择"下底座"后,日志记录 STRUCTURE_SELECT group=7STRUCTURE_INSPECT part=下底座 radius=4.600000STRUCTURE_FOCUS part=下底座 x=2.80 z=0.21。离开页面时连续收到 STRUCTURE_RELEASE timer=trueSTRUCTURE_RELEASE timer=false,证明定时器和场景都完成释放。

近景截图和原始日志保存在 docs/qa/structure-studio-detail/,八个零件的逐件原始帧与日志保存在 docs/qa/structure-studio-parts/。该目录中的 8 张 GIF 均来自 Mate 60 Pro 真机画面,每张按"侧置近景 → 旋转部件 → 水平拖动 → 俯仰拖动"循环播放;文章中的图片统一按 50% 宽高展示,阅读时可以逐件对照模型结构。

案例使用本地 GLB,不需要网络请求;模型内部是可复现的参数化几何示意。若要替换产品,只需保持节点组名并重新生成对应 mesh,页面的进度、选中和相机逻辑可以继续复用。

相关推荐
李游Leo2 小时前
HarmonyOS 7 性能优化:List 长列表懒加载与缓存实践
harmonyos
威哥爱编程2 小时前
HarmonyOS 碰一碰与隔空传送实战:真正的坑在 3 秒铁律和生命周期
harmonyos
威哥爱编程2 小时前
HarmonyOS 扫码直达接入实战:系统扫、应用落,三步送用户进履约页
华为·harmonyos·arkts
威哥爱编程3 小时前
HarmonyOS 6.0 智感握姿实战:一道安检门、五态分诊、一静一响两个坑
华为·harmonyos·arkts
李游Leo3 小时前
HarmonyOS 7 音频与媒体控制实战 02:实现系统音频内录与状态控制
harmonyos
李游Leo3 小时前
HarmonyOS 7 音频与媒体控制实战 05:排查播放无声、卡顿和杂音问题
harmonyos
大雷神3 小时前
【共创稿事节】HarmonyOS ArkGraphics 3D 实操:用 GLB 节点与 PBR 材质打造智能音箱选配器
harmonyos
贾伟康12 小时前
【HarmonyOS 7新能力|020】LazyLayoutAlgorithm入门实战:从能力边界到最小可运行链路
harmonyos·arkts·arkui·harmonyos 7·lazylayout
梦想不只是梦与想14 小时前
鸿蒙 邀请测试:发布测试版本
harmonyos·appgallery 邀请测试·邀请测试