【共创稿事节】HarmonyOS ArkGraphics 3D 实操:用 GLB 节点与 PBR 材质打造智能音箱选配器

换一种颜色,只能算外观预览。真正的产品选配器,还要能更换组件、切换材质,并让配置清单和价格同步变化。

这一篇继续升级 OrbitDesk:同一台 GLB 智能音箱可以选择三种机身颜色、两种顶部灯环和两种底座材质。每次点击都会直接修改模型节点的 PBR 材质,页面右上角和配置卡同步计算价格,最后还能确认或一键重置方案。

三条交互链路连续演示

先看真实设备上的三个关键动作:换机身/灯环/底座材质、直接拖动 3D 音箱进行上下左右观察、确认后再重置配置。下面三张 GIF 均由真机连续帧编码,循环播放时可以看到模型、选项状态和价格一起变化。

先看完成效果

打开页面,智能音箱会在蓝紫渐变灯光中自动环绕。切换"机身、灯环、底座"标签时,发光热点会移动到对应部件,画面同时显示真实的 GLB 节点名;下方只保留当前部件的选项,让视线始终停留在正在变化的 3D 模型上。默认组合为"极夜蓝 + 薄荷青环 + 雾面底座",价格是 ¥899。

选择珊瑚橙不会增加价格;换成暮光紫环增加 ¥129;再换成镜面金属底座增加 ¥99,最终合计 ¥1127。机身、灯环和底座的变化都直接出现在 3D 模型上。

这次要操作哪些 GLB 节点

项目继续使用本地 orbitdesk_speaker.glb,运行时不需要网络。选配器加载模型后会找到五个关键节点:

  • OrbitDeskProduct:整台产品的根节点,负责旋转。
  • SpeakerBody:机身外壳,负责颜色切换。
  • TopRing:顶部灯环,负责薄荷青环和暮光紫环切换。
  • MetalBaseUpper:上层底座。
  • MetalBaseLower:下层底座。

其中两个底座节点共用同一个动态材质,这样切换时不会出现上下两层质感不一致的问题。

1. 准备三组产品选项

每项配置同时保存显示名称、价格和材质参数。机身颜色只改变外观,不加价;灯环和底座则带有各自的升级价格。

复制代码
interface RingOption {
  name: string;
  price: number;
  color: Vec4;
  emissive: number;
}

interface BaseOption {
  name: string;
  price: number;
  color: Vec4;
  roughness: number;
  metallic: number;
}

const RING_OPTIONS: RingOption[] = [
  {
    name: '薄荷青环',
    price: 0,
    color: { x: 0.01, y: 0.86, z: 1.0, w: 1 },
    emissive: 1.8
  },
  {
    name: '暮光紫环',
    price: 129,
    color: { x: 0.68, y: 0.12, z: 1.0, w: 1 },
    emissive: 3.2
  }
];

const BASE_OPTIONS: BaseOption[] = [
  {
    name: '雾面底座',
    price: 0,
    color: { x: 0.23, y: 0.28, z: 0.34, w: 1 },
    roughness: 0.52,
    metallic: 0.56
  },
  {
    name: '镜面金属',
    price: 99,
    color: { x: 0.66, y: 0.72, z: 0.80, w: 1 },
    roughness: 0.12,
    metallic: 0.96
  }
];

粗糙度越低,高光越集中;金属度越高,底座越接近抛光金属。薄荷青环使用 1.8 的自发光强度,暮光紫环提升到 3.2。两种灯环在自动旋转和蓝紫渐变背景中都能保持柔和轮廓,暮光模式会出现明显的紫色光带。

2. 加载 GLB 并找到可配置节点

加载应用内 GLB 时直接使用 $rawfile。场景就绪后递归遍历节点树,并检查目标节点是否真的是 Geometry

复制代码
this.scene = await Scene.load($rawfile('orbitdesk_speaker.glb'));
this.factory = this.scene.getResourceFactory();

this.productRoot = this.findNodeByName(
  this.scene.root, 'OrbitDeskProduct');
this.body = this.requireGeometry('SpeakerBody');
this.topRing = this.requireGeometry('TopRing');
this.baseParts = [
  this.requireGeometry('MetalBaseUpper'),
  this.requireGeometry('MetalBaseLower')
];

if (!this.productRoot) {
  throw new Error('GLB 缺少 OrbitDeskProduct 节点');
}

requireGeometry() 把"找节点"和"检查节点类型"合在一起。只要模型资源缺少任意关键部件,页面就会进入加载失败状态,而不是让按钮继续操作空引用。

复制代码
private requireGeometry(name: string): Geometry {
  const node = this.findNodeByName(this.scene?.root ?? null, name);
  if (!node || node.nodeType !== NodeType.GEOMETRY) {
    throw new Error(`GLB 缺少 ${name} 几何节点`);
  }
  return node as Geometry;
}

private findNodeByName(node: Node | null,
  name: string): Node | undefined {
  if (!node) {
    return undefined;
  }
  if (node.name === name) {
    return node;
  }
  for (let index = 0; index < node.children.count(); index++) {
    const found = this.findNodeByName(
      node.children.get(index), name);
    if (found) {
      return found;
    }
  }
  return undefined;
}

3. 为选项创建动态 PBR 材质

三组配置使用同一个材质创建方法。基础色、粗糙度、金属度和自发光值都来自当前选项。

复制代码
private async createPbrMaterial(
  name: string,
  color: Vec4,
  roughness: number,
  metallic: number,
  emissive: number
): Promise<MetallicRoughnessMaterial> {
  const material = await this.factory!.createMaterial(
    { name },
    MaterialType.METALLIC_ROUGHNESS
  ) as MetallicRoughnessMaterial;

  material.baseColor.factor = color;
  material.material.factor = {
    x: 1,
    y: roughness,
    z: metallic,
    w: 0.5
  };
  material.emissive.factor = {
    x: color.x * emissive,
    y: color.y * emissive,
    z: color.z * emissive,
    w: 1
  };
  this.materials.push(material);
  return material;
}

机身切换时,把新材质覆盖到 SpeakerBody.mesh.materialOverride

复制代码
private async applyBody(index: number): Promise<void> {
  this.forgetMaterial(this.bodyMaterial);
  const option = BODY_OPTIONS[index];
  this.bodyMaterial = await this.createPbrMaterial(
    `ConfigBody_${index}`,
    option.color,
    option.roughness,
    option.metallic,
    0.05
  );
  this.body!.mesh.materialOverride = this.bodyMaterial;
}

灯环不仅替换 TopRing 材质,还把环体沿水平面外扩到 1.18 倍、沿高度方向加粗到 3.4 倍。这里改变的仍是 GLB 中真实的灯环节点,因此环体会跟随产品一起旋转:

复制代码
private applyRingPresentation(): void {
  if (!this.topRing) {
    return;
  }
  const pulse = this.activeCategory === 1
    ? 1 + Math.sin(this.pulsePhase) * 0.045 : 1;
  this.topRing.scale = {
    x: 1.18 * pulse,
    y: 3.4,
    z: 1.18 * pulse
  };
}

底座则把同一个材质同时交给上下两个节点:

复制代码
private async applyBase(index: number): Promise<void> {
  this.forgetMaterial(this.baseMaterial);
  const option = BASE_OPTIONS[index];
  this.baseMaterial = await this.createPbrMaterial(
    `ConfigBase_${index}`,
    option.color,
    option.roughness,
    option.metallic,
    0.03
  );
  this.baseParts.forEach((part: Geometry) => {
    part.mesh.materialOverride = this.baseMaterial;
  });
}

切换前先销毁上一份动态材质,再把它从资源数组中移除。连续点击不同方案时,页面始终只保留当前机身、灯环和底座材质。

4. 让选项、模型、摘要和价格同步

三个 @State 索引决定当前方案,价格不单独保存,而是每次根据状态计算:

复制代码
@State private bodyIndex: number = 0;
@State private ringIndex: number = 0;
@State private baseIndex: number = 0;

private totalPrice(): number {
  return 899 +
    RING_OPTIONS[this.ringIndex].price +
    BASE_OPTIONS[this.baseIndex].price;
}

点击暮光紫环后,ringIndex、3D 灯环材质、配置摘要和价格会在同一次操作中更新:

复制代码
private async switchRing(index: number): Promise<void> {
  this.ringIndex = index;
  this.confirmedText = '';
  await this.applyRing(index);
  hilog.info(DOMAIN, TAG,
    'CONFIG_RING option=%{public}s add=%{public}d price=%{public}d',
    RING_OPTIONS[index].name,
    RING_OPTIONS[index].price,
    this.totalPrice());
}

这样的状态结构不会出现"模型已经换成暮光紫环,摘要还显示薄荷青环"的错位。用户继续修改已确认方案时,确认提示也会自动清空。

5. 让选项直接指向 3D 部件

选配器最怕"按钮在变化,用户却不知道模型改了哪里"。这里把三组配置做成焦点标签:点击机身时热点指向 SpeakerBody,点击灯环时热点抬到 TopRing,点击底座时热点落到 MetalBase。底座在模型中由 MetalBaseUpperMetalBaseLower 两个节点组成,界面用一个名称把它们作为整体呈现。

焦点切换还会清空旧的确认提示,并用一次 280ms 的缩放动画回应点击:

复制代码
private focusCategory(index: number): void {
  this.activeCategory = index;
  this.confirmedText = '';
  this.pulsePhase = 0;
  this.hotspotScale = 1.14;
  this.getUIContext().animateTo(
    { duration: 280, curve: Curve.EaseOut },
    () => this.hotspotScale = 1
  );
  hilog.info(DOMAIN, TAG,
    'CONFIG_FOCUS category=%{public}s node=%{public}s',
    ['body', 'ring', 'base'][index], this.focusNodeName());
}

完成三项配置后,热点仍然停在当前部件上。用户既能看到珊瑚橙机身、紫色暮光紫环和镜面底座的组合,也能立刻知道这次操作修改了哪个模型节点。

6. 自动环绕与手势接管

模型加载完成后启动定时器,每 50ms 把 OrbitDeskProduct 绕 Y 轴推进 0.5°。角度越过 180° 时回到 -179.5°,因此旋转可以持续进行;热点也会随时间轻微呼吸。

复制代码
private startAutoSpin(): void {
  if (this.spinTimer !== undefined || !this.productRoot) {
    return;
  }
  this.autoSpin = true;
  this.spinTimer = setInterval(() => {
    this.rotation = this.rotation >= 180
      ? -179.5 : this.rotation + 0.5;
    this.applyRotation(this.rotation);
    this.pulsePhase += 0.12;
    this.hotspotScale = 1 + Math.sin(this.pulsePhase) * 0.08;
    this.applyRingPresentation();
  }, 50);
}

自动展示不会抢走操作权。手指按下模型时先停止定时器,横向位移映射相机环绕,向下拖动最多看到 -16° 的底部视角,向上拖动限制在 +12°,可以实际看到顶部灯环、机身底部和底座下沿,同时避开设备渲染器的极端视角接缝。GLB 产品节点保持静态,避免层级网格在旋转时产生错位。点击"继续环绕"即可恢复自动展示。

复制代码
PanGesture({ fingers: 1, direction: PanDirection.All, distance: 2 })
  .onActionStart(() => {
    this.stopAutoSpin(true);
    this.dragStartRotation = this.rotation;
    this.dragStartPitch = this.pitch;
  })
  .onActionUpdate((event: GestureEvent) => {
    this.rotation = this.dragStartRotation + event.offsetX * 0.55;
    this.pitch = Math.max(-16, Math.min(12,
      this.dragStartPitch - event.offsetY * 0.16));
    this.applyRotation(this.rotation, this.pitch);
  });

真机画面中的实时角度同时显示左右 yaw 和上下 pitch。向上拖动可看到灯环内侧,向下拖动可看到底座上沿,左右拖动则查看网罩孔和机身分界;手势拖动后自动环绕暂停,按钮恢复后角度再次持续变化。这里改变的是相机球面位置和朝向,产品的 GLB 层级不再被直接旋转。

手势事件先进入待提交角度,页面用 80ms 防抖合并姿态更新,抬手时再提交最终值。这样快速上下拖动不会让渲染线程重复处理同一段位移,真机画面也更稳定。

7. 确认与一键重置

确认配置时,页面保留当前方案,并在摘要下方显示最终价格:

复制代码
private confirmConfig(): void {
  this.confirmedText = `方案已确认 · ¥${this.totalPrice()}`;
  hilog.info(DOMAIN, TAG,
    'CONFIG_CONFIRM body=%{public}s ring=%{public}s ' +
    'base=%{public}s price=%{public}d',
    BODY_OPTIONS[this.bodyIndex].name,
    RING_OPTIONS[this.ringIndex].name,
    BASE_OPTIONS[this.baseIndex].name,
    this.totalPrice());
}

重置会恢复三个默认索引、0° 角度和 ¥899,同时重新应用三份默认材质:

复制代码
private async resetConfig(): Promise<void> {
  this.bodyIndex = 0;
  this.ringIndex = 0;
  this.baseIndex = 0;
  this.rotation = 0;
  this.confirmedText = '';
  await this.applyBody(0);
  await this.applyRing(0);
  await this.applyBase(0);
  this.applyRotation(0);
}

8. 退出时释放动态材质和场景

选配器会频繁替换材质,因此退出时除了销毁 Scene,还要销毁当前持有的动态材质并清空节点引用。返回按钮和 aboutToDisappear() 共用同一个幂等释放方法。

复制代码
private releaseScene(): void {
  const hadScene = this.scene ? 1 : 0;
  this.materials.forEach((material: Material) => material.destroy());
  this.scene?.destroy();
  this.materials = [];
  this.bodyMaterial = undefined;
  this.ringMaterial = undefined;
  this.baseMaterial = undefined;
  this.baseParts = [];
  this.body = undefined;
  this.topRing = undefined;
  this.productRoot = undefined;
  this.scene = undefined;
  this.sceneOpt = undefined;
  hilog.info(DOMAIN, TAG,
    'CONFIG_RELEASE scene=%{public}d', hadScene);
}

真机运行记录

本次在 HUAWEI Mate 60 Pro、HarmonyOS 7.0.0.105 上完成运行。下面的日志对应默认加载、部件焦点、三类材质切换、自动环绕的暂停与恢复、确认、重置和退出释放:

复制代码
CONFIG_READY model=glb nodes=5 body=极夜蓝 ring=薄荷青环 base=雾面底座 price=899
CONFIG_AUTOSPIN running=true
CONFIG_FOCUS category=ring node=TopRing
CONFIG_RING option=暮光紫环 add=129 price=1028
CONFIG_FOCUS category=base node=MetalBase
CONFIG_BASE option=镜面金属 add=99 price=1127
CONFIG_FOCUS category=body node=SpeakerBody
CONFIG_BODY option=珊瑚橙 price=1127
CONFIG_AUTOSPIN running=false degrees=160
CONFIG_CONFIRM body=珊瑚橙 ring=暮光紫环 base=镜面金属 price=1127
CONFIG_AUTOSPIN running=true
CONFIG_RESET price=899
CONFIG_RELEASE scene=1
CONFIG_RELEASE scene=0

OrbitDesk 会用自动环绕主动展示产品,也会在用户触摸时把镜头控制权交出来。每一次部件切换都有热点指向真实节点,材质、价格和配置摘要同步更新。到这里,它已经具备一套 3D 产品选配器应有的浏览、选择、观察、确认和重置体验。

相关推荐
贾伟康10 小时前
【HarmonyOS 7新能力|020】LazyLayoutAlgorithm入门实战:从能力边界到最小可运行链路
harmonyos·arkts·arkui·harmonyos 7·lazylayout
梦想不只是梦与想11 小时前
鸿蒙 邀请测试:发布测试版本
harmonyos·appgallery 邀请测试·邀请测试
马剑威(威哥爱编程)13 小时前
【共创稿事节】HarmonyOS 7 应用 Skill 化实战:从“被打开“到“被调用“,把功能递进系统意图分发池
pytorch·深度学习·harmonyos
HarmonyOS_SDK15 小时前
HarmonyOS Push Kit 自分类权益 Skill,助力提升权益申请通过率
harmonyos
周胡杰17 小时前
将现有 Compose Multiplatform 业务接入 HarmonyOS:架构、适配与持续同步
harmonyos·鸿蒙·cmp
星栖与芯18 小时前
LiteOS-M 切换汇编逐行图解(1):汇编是什么·寄存器与栈
汇编·stm32·嵌入式硬件·harmonyos·鸿蒙系统
HwJack2018 小时前
【HarmonyOS开发小实践】ArkUI 应用级状态AppStorage 与跨页面共享、持久化
ui·华为·性能优化·harmonyos
万物智能信息科技21 小时前
板载按键key的ADC转换和信号控制—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
嵌入式硬件·华为·开源·harmonyos·鸿蒙
威哥爱编程1 天前
HarmonyOS 6.1 端侧 3DGS 重建实战:重建在 C 层,ArkTS 只管"看"和"改"
华为·harmonyos·arkts