如果一款智能音箱出现在商城页面,最直观的体验不是先读一串参数,而是换个颜色、转一圈,看清机身、网罩和金属底座的细节。OrbitDesk 把这个过程搬到了 HarmonyOS:本地 GLB 负责造型,PBR 材质负责质感,滑杆和手势负责观察角度。
接下来从资源加载、灯光、换色、旋转到退出释放,完整做出这个可以在手机上直接操作的 3D 外观预览器。项目资源全部打包在应用内,运行时无需联网。

先看真机效果
页面保留产品展示所需的最少控件:左上角返回按钮、底部三枚材质按钮和旋转滑杆。模型包含圆角机身、七排网罩孔、顶部青色发光环、琥珀色状态灯、双层金属底座和闭合光轨。
本次在 HUAWEI Mate 60 Pro、HarmonyOS 7.0.0.105 上运行。页面启动后直接加载本地 GLB,三种机身配色、滑杆旋转和模型拖动均可操作。
动态效果展示
先看三段最直观的操作:换色、拖动滑杆、直接拖动音箱。每张 GIF 都来自真机连续画面。
材质切换
依次点击极夜蓝、雾银灰和珊瑚橙按钮,选中环与机身 PBR 材质同步变化。

滑杆拖动旋转
拖动底部滑杆时,滑块位置和产品根节点的 Y 轴角度连续变化。

直接拖动模型
在模型区域横向拖动,不触碰滑杆;音箱旋转后,底部滑块同步到当前角度。

工程文件
- 页面:entry/src/main/ets/features/graphics3d/OrbitDeskPage.ets
- 3D 主页:entry/src/main/ets/features/graphics3d/Graphics3DHomePage.ets
- GLB:entry/src/main/resources/rawfile/orbitdesk_speaker.glb
- 材质说明:entry/src/main/resources/rawfile/orbitdesk_speaker_materials.md
- GLB 生成脚本:tools/generate-orbitdesk-glb.js
生成后的 orbitdesk_speaker.glb 为自包含 glTF 2.0 二进制文件,大小 175416 字节,包含 10 个节点、9 个网格和对应 PBR 材质。Khronos glTF Validator 检查结果为 0 error、0 warning、0 info、0 hint。
1. 用 $rawfile 加载 GLB
页面先尝试加载真实 GLB;只有捕获到加载异常时,才进入程序化几何回退,并记录 GLB_LOAD_FAILED。这样既保证实验页可诊断,也不会把回退画面写成资源加载成功。
try {
this.scene = await Scene.load($rawfile('orbitdesk_speaker.glb'));
this.renderMode = 'glb';
hilog.info(DOMAIN, TAG, 'ORBIT_INIT step=scene_load_done mode=glb');
} catch (glbError) {
const loadError = glbError as BusinessError;
hilog.warn(DOMAIN, TAG,
'GLB_LOAD_FAILED code=%{public}d message=%{public}s fallback=procedural',
loadError.code ?? -1, loadError.message ?? `${glbError}`);
this.scene = await Scene.load();
this.renderMode = 'procedural-fallback';
}
真机调试时遇到一个关键差异:最初传入 resource://rawfile/orbitdesk_speaker.glb 字符串,设备返回 Scene creation failed;改成 $rawfile('orbitdesk_speaker.glb') 后,同一台设备记录 scene_load_done mode=glb,模型随即正常显示。因此,工程最终使用 $rawfile 加载应用内资源。
2. 配置相机与双色方向光
相机位于 Z 轴正方向,朝向模型原点。暖色主光负责右侧金属高光,青色轮廓光补出左侧边缘,与顶部和底部发光环形成统一色彩关系。
this.camera = await this.factory.createCamera({ name: 'OrbitCamera' });
this.camera.enabled = true;
this.camera.position = { x: 0, y: 0.12, z: 7.8 };
this.camera.fov = 0.72;
this.keyLight = await this.factory.createLight(
{ name: 'WarmKey' }, LightType.DIRECTIONAL) as DirectionalLight;
this.keyLight.intensity = 4;
this.keyLight.color = { r: 1, g: 0.66, b: 0.38, a: 1 };
this.rimLight = await this.factory.createLight(
{ name: 'CyanRim' }, LightType.DIRECTIONAL) as DirectionalLight;
this.rimLight.intensity = 9;
this.rimLight.color = { r: 0.16, g: 0.74, b: 1, a: 1 };
3. 找到机身节点并切换 PBR 材质
GLB 中的产品根节点名为 OrbitDeskProduct,可换色机身节点名为 SpeakerBody。场景加载后递归查找节点;任一关键节点缺失都抛出真实错误,不继续显示“已就绪”。
换色时创建新的 METALLIC_ROUGHNESS 材质,设置基础色、粗糙度和金属度,再覆盖到机身网格。替换完成后销毁旧动态材质,避免用户连续点按时累积资源。
this.bodyMaterial = await this.factory.createMaterial(
{ name: `BodyMaterial_${index}` },
MaterialType.METALLIC_ROUGHNESS) as MetallicRoughnessMaterial;
this.bodyMaterial.baseColor.factor = palette.body;
this.bodyMaterial.material.factor = {
x: 1,
y: palette.roughness,
z: palette.metallic,
w: 0.5
};
this.bodyParts.forEach((part: Geometry) => {
part.mesh.materialOverride = this.bodyMaterial;
});
点击第三枚按钮后,机身变为珊瑚橙,选中环同步移动到第三枚按钮。真机日志记录 COLOR_SWITCH palette=珊瑚橙。
4. 让滑杆和模型拖动共用旋转状态
滑杆范围为 -135° 到 135°。角度转为弧度后,写入产品根节点的 Y 轴四元数;底座、光轨和音箱作为同一产品节点一起旋转。
private applyRotation(degrees: number): void {
if (!this.productRoot) {
return;
}
const radians = degrees * Math.PI / 180;
this.productRoot.rotation = {
x: 0,
y: Math.sin(radians / 2),
z: 0,
w: Math.cos(radians / 2)
};
}
真机拖动滑杆后,角度连续变化并停在约 115°,网罩孔和状态灯移动到侧面,说明旋转来自 3D 节点变换,不是替换静态图片。
为了让用户也能直接拖音箱,模型区增加横向 PanGesture。手势开始时保存当前角度,更新时把横向位移映射成角度,并写回同一个 rotation 状态,因此底部滑杆会同步移动。
PanGesture({ fingers: 1, direction: PanDirection.Horizontal, distance: 2 })
.onActionStart(() => {
this.dragStartRotation = this.rotation;
})
.onActionUpdate((event: GestureEvent) => {
const requested = this.dragStartRotation + event.offsetX * 0.55;
const clamped = Math.max(-135, Math.min(135, requested));
this.rotation = clamped;
this.applyRotation(clamped);
})
5. 解决 Component3D 吞掉触摸的问题
首轮真机上模型能显示,但材质按钮、滑杆和返回按钮都无法稳定响应。原因是全屏 Component3D 参与了命中测试。最终把 3D 组件设为只渲染,并在其上放置独立手势层和控制层:
Component3D(this.sceneOpt)
.width('100%')
.height('100%')
.hitTestBehavior(HitTestMode.None)
.zIndex(0)
// 模型手势层位于控制区上方,避免抢占按钮和滑杆事件。
Row()
.width('100%')
.height('67%')
.position({ x: 0, y: 64 })
.hitTestBehavior(HitTestMode.Block)
.zIndex(1)
按钮和滑杆所在控制层使用更高 zIndex。修正后,真机固定坐标操作依次触发换色、滑杆旋转、模型拖动和返回,四条链路都有截图与 Hilog。
6. 退出时释放场景
返回按钮先调用 releaseScene(),再切换页面。释放逻辑销毁程序化回退可能创建的几何体、动态材质和 Scene,并清空页面持有的节点引用。aboutToDisappear() 也调用同一方法,因此日志里会出现两次释放记录:第一次 scene=1 表示真实 Scene 被销毁,第二次 scene=0 表示引用已为空,只做幂等收口。
private releaseScene(): void {
const hadScene: number = this.scene ? 1 : 0;
this.geometries.forEach((geometry: Geometry) => geometry.destroy());
this.materials.forEach((material: Material) => material.destroy());
this.scene?.destroy();
this.geometries = [];
this.materials = [];
this.productRoot = undefined;
this.scene = undefined;
this.sceneOpt = undefined;
hilog.info(DOMAIN, TAG, 'ORBIT_RELEASE scene=%{public}d', hadScene);
}

设计稿对照
真机截图去掉系统状态栏和导航栏后,按等比方式放入 942 × 1672 的设计稿画布,并生成并排图和 50% 透明叠加图。对照确认页面结构已经一致:深夜蓝画布、圆角音箱、网罩孔、上下发光环、双层底座、三枚圆形材质按钮和细滑杆均已落地。

现在这个版本已经串起 GLB 加载、PBR 换色、相机灯光、双入口旋转和资源释放。继续打磨时,可以把网罩纹理、金属拉丝和机身比例交给更精细的美术资产,而页面交互代码无需推倒重来。
真机日志
ORBIT_INIT step=scene_load_start
ORBIT_INIT step=scene_load_done mode=glb
ORBIT_READY mode=glb camera=1 lights=2 resource=orbitdesk_speaker.glb
COLOR_SWITCH palette=珊瑚橙
SLIDER_ROTATE degrees=115
ORBIT_DRAG_START degrees=115
ORBIT_DRAG_END degrees=26
ORBIT_RELEASE scene=1
到这里,OrbitDesk 已经形成从资源加载到退出释放的完整闭环。上面这组真机日志可以在复现时快速核对关键步骤是否执行到位。
网硕互联帮助中心








评论前必须登录!
注册