鸿蒙PC 桌面应用实战:ArkTS + Canvas 自绘高斯点云工作台,兼论 ArkUI 跨平台框架的选型边界

欢迎加入开源鸿蒙PC社区: https://harmonypc.csdn.net/
欢迎在PC社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

导读:本文围绕开源鸿蒙PC 生态,以一个真实可运行的「澜境 · Gaussian Studio」工程为样本,完整拆解鸿蒙PC 桌面形态(2in1)应用的窗口断点、鼠标交互、Canvas 2D 自绘渲染管线与前端状态机;同时把 ArkUI 跨平台框架 作为贯穿全文的选型参照,讨论在鸿蒙PC 上「哪些层适合跨端复用、哪些层必须保持原生」。文中所有代码均摘自工程源码,所有界面截图均为鸿蒙PC 设备上的完整桌面运行截图。


鸿蒙PC 设备上运行的影青刻花盏三维视口

一、为什么要在鸿蒙PC 上讨论一个「三维重建工作台」

很多开发者把鸿蒙应用等同于「手机上的一屏 UI」,但鸿蒙PC 带来的变化不是简单的分辨率放大。它至少改变了五件事:窗口可以自由缩放、鼠标与触控板成为主输入、键盘快捷键与悬停态成为基本交互语言、任务可以长时间在后台运行、本地磁盘与本地算力可以被当作系统资源来调度。这些变化决定了:把一个手机页面直接搬到鸿蒙PC 上,几乎一定会得到一个「四周留白、控件稀疏、鼠标无处下手」的界面。也正因为如此,鸿蒙PC 应用的架构讨论,天然要和具体跨平台框架放在一起谈——因为跨平台框架解决的是「一套界面代码跑在多个系统」,而桌面形态解决的是「同一套代码如何适应宽屏、鼠标与多窗口」,两者并不等价。

选择「三维重建/数字馆藏」作为样例场景,是因为它同时命中鸿蒙PC 的三项优势。第一,文物的多视角影像往往涉及馆藏数据,不适合随意外传,端侧处理天然契合「数据不出本机」的诉求;第二,三维场景预览需要一块足够大的连续画布,鸿蒙PC 的宽屏比手机更合适;第三,重建、优化、导出都是耗时任务,桌面设备更适合承载长任务与任务中心。3D Gaussian Splatting(3DGS)近年在实时新视角合成领域很受关注,其核心思路是用大量带协方差与球谐系数的三维高斯基元表达场景,再通过可微光栅化渲染,因此一个面向它的工作台必然包含「视口 + 参数检查器 + 流水线 + 质量诊断」这几块。

这里必须先做一个非常重要的交代,避免读者产生误读:本文配套工程是一个界面与交互原型,不是一套已经训练出高斯模型的重建引擎。界面上的 1.24M Gaussians、PSNR 31.8 dB、SSIM 0.961、LPIPS 0.083、本机 NPU 加速、第 30,000 次迭代完成 均为演示用占位文案;三维视口是 ArkUI 的 Canvas 2D 画布用确定性伪随机点云实时绘制出来的视觉效果。把这一点讲清楚,比把截图包装成「端侧大模型推理」更有价值——因为读者可以据此判断哪些代码值得直接复用(窗口适配、交互、布局、状态管理),哪些必须自己补上(真实重建、推理、质量评测)。后文第九节会给出一条从原型走向真实管线的可落地路线。

为什么要引入 ArkUI-X 跨平台框架 作为对照?因为在开源鸿蒙PC 社区里,团队最常问的一个问题是:「我们已经有一套 ArkTS/ArkUI 界面,要不要为鸿蒙PC 单独写一套,或者干脆引入 ArkUI-X 跨平台框架把界面复用到更多系统?」这个问题没有统一答案。本文的做法是:先把鸿蒙PC 原生工程的每一层拆开,标出哪些是平台无关能力、哪些是强平台相关能力,再给出一份可执行的选型矩阵。这样讨论跨平台框架时才不会停留在口号层面。本文所称「跨平台框架」如无特别说明,即指 ArkUI-X 跨平台框架;其他方案只在对比表中作为技术参照出现。

二、工程全貌:一个零依赖、零 .so 的鸿蒙PC 原生工程

先看事实。工程根目录名 Gaussian_ohos,应用名「澜境 · Gaussian Studio」,包名 com.lanjing.gaussianstudio,模块描述是「端侧 3DGS 重建与质量诊断工作台」。工程级 oh-package.json5 里 dependencies 是空对象,entry/oh-package.json5 同样为空,hvigor/hvigor-config.json5 也为空;所有源码只导入了 @kit.AbilityKit、@kit.ArkUI、@kit.PerformanceAnalysisKit 三个系统 Kit,其余都是本地相对导入。换句话说,这是一个 100% 原生 ArkTS + ArkUI 声明式 UI 的 Stage 模型应用,没有 WebView、没有 three.js、没有 C++/NAPI、没有 .so,也没有引入任何第三方跨平台框架。构建产物 entry-default-signed.hap 约 482 KB,base_native_libs.json 里的 libs 为空,进一步印证了它并不包含原生渲染库。

这一点对「跨平台框架」的讨论尤其关键:如果工程里已经嵌入了某个跨平台框架,我们讨论的就是「框架如何适配鸿蒙PC」;而这里的工程恰好是纯原生实现,因此更适合用来回答反过来那个问题——「如果将来要引入 ArkUI-X 跨平台框架,哪些代码可以平滑搬过去,哪些代码会卡住」。后文第七节会沿着这条线索展开。

文件 / 目录在鸿蒙PC 工程中的职责
AppScope/app.json5应用级信息:包名 com.lanjing.gaussianstudio、版本 1.0.0、厂商标识
build-profile.json5签名配置、product default、compatibleSdkVersion 与 targetSdkVersion 均为 5.0.5(17)、runtimeOS: HarmonyOS
oh-package.json5工程描述明确写着面向 HarmonyOS PC;license: Apache-2.0;dependencies: {}
entry/src/main/module.json5只声明 deviceTypes: ['2in1'],桌面形态适配的起点
ets/entryability/EntryAbility.ets唯一 UIAbility,loadContent('pages/Index') 并打三条 hilog
ets/pages/Index.ets唯一页面(约 620 行):16 个 @State、顶栏、图标栏、三栏工作区、流水线与状态栏
ets/common/Models.ets三个文物项目与四段流水线的字面量数据
ets/common/Theme.ets深色配色与字号常量
ets/components/AppIcon.ets用 Path 命令字符串手绘的二十余个矢量图标,无图片资源
ets/components/GaussianViewport.ets三维视口:Canvas、拖拽旋转、双指捏合、滚轮缩放、四种着色模式
ets/components/InspectorPanel.ets右侧场景检查器:质量环、三项指标、质量预设、两个 Slider、优化按钮
ets/components/ProjectSidebar.ets左侧项目列表、搜索框、工作空间容量条
ets/components/WorkspaceDialogs.ets新建重建、导出场景、任务中心、偏好、视图帮助五种对话框
ets/rendering/GaussianRenderer.ets唯一的「渲染引擎」:确定性点云生成 + 2D 软件投影与绘制

整个应用的分层关系如下图所示。左侧是自上而下的五层原生实现,右侧标出了 ArkUI-X 跨平台框架在其中的可复用边界:声明式界面层是跨端友好的,而 2in1 设备形态、鼠标轴事件、高分屏像素换算与 Canvas 渲染管线都带有明显的鸿蒙PC 平台属性。

鸿蒙PC 原生工程分层架构与 ArkUI-X 跨平台框架的复用边界

图 1 鸿蒙PC 原生工程分层架构,以及 ArkUI-X 跨平台框架的可复用边界(作者自绘)

需要说明的是,构建过程中出现的 *.msgpack、*.protoBin 文件位于 entry/build/default/cache/.../CompileArkTS/ 下,是 ArkTS 编译器的中间缓存与转译表示,不是模型权重;resources 目录里只有两个 app_icon.svg 和三个 JSON 资源,不存在 .ply、.splat、.onnx 之类的模型文件。把这类构建产物误当成「端侧模型」是阅读鸿蒙PC 工程时很常见的一个坑。


三、鸿蒙PC 真机运行截图:完整桌面界面

下面四张图都来自鸿蒙PC 设备上的实际运行界面,且均为完整桌面截图而非局部裁剪:可以看到屏幕底部的系统 Dock、右上角的系统状态栏与时间、桌面上同时运行的 DevEco Code 开发环境窗口,以及置于前台的「澜境」应用窗口。这种「桌面外壳 + 应用窗口 + 开发环境」同时可见的画面,能够一眼确认运行目标是鸿蒙PC,而不是手机模拟器或网页预览。图片均已上传图床,便于在社区文章中直接引用。

鸿蒙PC 设备上运行的影青刻花盏三维视口

图 2 鸿蒙PC 设备完整桌面:项目「影青刻花盏」,三维场景页签下的点云视口与场景检查器,左侧可见工程目录树,底部可见系统 Dock 与状态栏

图 2 是默认三栏工作台:左侧项目侧栏列出「青釉弦纹瓶 / 影青刻花盏 / 铜鎏金兽」三个项目,中间是带透视网格地面的点云视口,右侧检查器显示 92.8 的质量环、31.8 / 0.961 / 0.083 三项指标、本机 NPU 加速 卡片、点尺寸 1.00× 与 不透明度 82% 两个 Slider。视口顶部还有「高斯 / 点云 / 法线 / 置信度」四个着色模式按钮。桌面宽度足够时,三栏同时展开而不互相挤压,这正是鸿蒙PC 相对手机端最直观的差别。

鸿蒙PC 设备上运行的青釉弦纹瓶与薄弱区域标记

图 3 切换项目到「青釉弦纹瓶」后开启薄弱区域标记,视口出现「肩部反光区域 · 覆盖率 72%」提示气泡,质量环变为 94.6

图 3 展示了状态联动:切换项目后视口从盏形变成瓶形,右侧指标与质量环同步刷新;打开「薄弱区域标记」开关,视口里叠加一个提示气泡。这组交互全部由 ArkUI 状态驱动,没有重新创建窗口、也没有加载新的资源文件。注意右侧质量预设此时切到了「精细」,但视口里的点数量其实没有变化——这一点将在第六节解释。

鸿蒙PC 设备上运行的重建日志页签

图 4 切换到「重建日志」页签,完整显示 8 行带时间戳的端侧重建日志,右侧检查器保持可见

图 4 是第三个页签。日志从「创建端侧重建任务」到「质量评估完成」按时间倒序排列,包含特征提取、位姿优化、迭代 loss、自适应密度控制等术语。请再次注意:这 8 行是页面里的静态字符串数组,用于演示任务中心的呈现形式,不是运行时 hilog 输出。把它当成真实训练日志去做性能结论,是阅读这类鸿蒙PC 原型工程时最容易踩的第二个坑。

鸿蒙PC 设备上 DevEco 签名配置与应用窗口同屏

图 5 在鸿蒙PC 桌面打开 DevEco 的「项目结构 → 签名配置」,应用窗口保持在前台;可见完整桌面 Dock、状态栏与工程结构树

图 5 补充了工程侧的证据:DevEco 的 Project/Product、产品信息、签名配置、编译选项 面板与应用窗口同屏,左侧是 Gaussian_ohos 的 AppScope / entry / hvigor 目录树。它同时说明了一个很实际的鸿蒙PC 开发事实——签名配置与 bundleName 绑定,换机器或换调试证书后必须重新签名才能在设备上安装。文章第十节会把这条整理进复现清单。


四、桌面形态适配:从「一屏页面」到「鸿蒙PC 工作台」

4.1 设备形态声明是第一道门槛

适配鸿蒙PC 的第一步不是布局,而是声明。工程在 entry/src/main/module.json5 里只写了 2in1:

{
  module: {
    name: 'entry',
    type: 'entry',
    description: '$string:module_desc',
    mainElement: 'EntryAbility',
    deviceTypes: ['2in1'],
    deliveryWithInstall: true,
    installationFree: false,
    pages: '$profile:main_pages',
    abilities: [
      {
        name: 'EntryAbility',
        srcEntry: './ets/entryability/EntryAbility.ets',
        description: '$string:EntryAbility_desc',
        icon: '$media:app_icon',
        label: '$string:EntryAbility_label',
        startWindowIcon: '$media:app_icon',
        startWindowBackground: '$color:start_window_background',
        exported: true,
        skills: [
          {
            entities: ['entity.system.home'],
            actions: ['action.system.home']
          }
        ]
      }
    ]
  }
}

2in1 是 PC / 二合一设备形态,这意味着 HAP 从打包阶段就面向桌面分发。好处是系统不会按手机窄屏逻辑去约束窗口;代价是这个包不能指望直接装到手机或普通平板上。对开源鸿蒙PC 生态的开发者来说,这条声明是「我要为鸿蒙PC 写应用」最明确的信号,也是后面所有断点、鼠标、悬停设计的前提。如果同一套界面还要覆盖手机和平板,就需要在这里增加设备形态,并让布局断点覆盖更宽的区间——这也正是引入 ArkUI-X 跨平台框架时首先要重新审视的配置之一。

4.2 用 onAreaChange 手写 1180 / 950 双断点

工程没有使用 GridRow、SideBarContainer 或媒体查询,而是用最朴素的方式做响应式:根容器测量自身宽度,写入 @State windowWidth,再用两个阈值决定侧栏是否出现。

// entry/src/main/ets/pages/Index.ets : 412-424
if (this.windowWidth >= 1180) {
  ProjectSidebar({
    projects: this.projects,
    selectedId: this.selectedId,
    onSelect: (id: number) => this.selectProject(id),
    onNew: () => {
      this.dialogMode = 'new';
    }
  })
    .width(226)
    .height('100%')
    .border({ width: { right: 1 }, color: StudioColors.subtleBorder })
}
// entry/src/main/ets/pages/Index.ets : 530-557(回调体按阅读长度折叠,语义不变)
if (this.windowWidth >= 950) {
  InspectorPanel({
    project: this.currentProject(),
    pointScale: this.pointScale,
    splatOpacity: this.splatOpacity,
    showWeakAreas: this.showWeakAreas,
    qualityMode: this.qualityMode,
    isOptimizing: this.isOptimizing && this.optimizingProjectId === this.selectedId,
    optimizeProgress: this.optimizeProgress,
    onPointScale: (value: number) => { this.pointScale = value; },
    onOpacity: (value: number) => { this.splatOpacity = value; },
    onWeakAreas: (value: boolean) => { this.showWeakAreas = value; },
    onQualityMode: (value: string) => {
      this.qualityMode = value;
      this.notify('已切换至' + value + '预设,将在下次优化时生效');
    },
    onOptimize: () => this.startOrPauseOptimization()
  })
    .width(288)
    .height('100%')
    .border({ width: { left: 1 }, color: StudioColors.subtleBorder })
}
// entry/src/main/ets/pages/Index.ets : 584-588
.width('100%')
.height('100%')
.onAreaChange((oldArea: Area, newArea: Area) => {
  this.windowWidth = Number(newArea.width);
})

三段代码合起来构成一个非常清晰的桌面布局策略:宽度不小于 1180 vp 时是「图标栏 64 + 项目侧栏 226 + 自适应视口 + 检查器 288」的完整三栏;在 950–1179 vp 之间收起项目侧栏,保留视口与检查器;小于 950 vp 时只保留图标栏与视口,把有限的宽度全部让给画布。中间视口始终用 layoutWeight(1) 吃掉剩余空间,因此窗口被鼠标拖宽或拖窄时不会溢出。顶栏固定 58、状态栏固定 25,面包屑、Tab、工具按钮这些桌面化元素才有稳定的落位。

鸿蒙PC 窗口宽度断点示意

图 6 鸿蒙PC 上的三栏 → 两栏 → 单栏布局策略,阈值 1180 / 950(作者自绘)

手写断点的优点是零依赖、行为完全可预测,也不会因为框架版本升级而改变布局;缺点是阈值散落在页面代码里,跨多个页面复用时要自己抽常量。这里给出一个可直接放进 ets/common/ 的小工具,把阈值收敛成纯函数,避免同一个数字在页面里出现多次:

export type PcLayoutMode = 'triple' | 'double' | 'single';

export function layoutModeOf(widthVp: number): PcLayoutMode {
  if (widthVp >= 1180) {
    return 'triple';
  }
  if (widthVp >= 950) {
    return 'double';
  }
  return 'single';
}

这个函数没有任何系统 Kit 依赖,属于典型的「平台无关逻辑」,在 ArkUI-X 跨平台框架里同样可以直接复用;真正需要平台适配的,是下面这些输入事件。

4.3 鼠标、悬停与高分屏:鸿蒙PC 交互的三个补完点

桌面用户不会像手机用户那样只用手指。GaussianViewport.ets 里除了 PanGesture 与 PinchGesture,还专门用 onAxisEvent 接住了滚轮:

// entry/src/main/ets/components/GaussianViewport.ets : 100-141
Canvas(this.context)
  .width('100%')
  .height('100%')
  .onReady(() => this.draw())
  .onAreaChange((oldArea: Area, newArea: Area) => {
    const density = this.getUIContext().vp2px(1);
    this.widthPx = Number(newArea.width) * density;
    this.heightPx = Number(newArea.height) * density;
    this.draw();
  })
  .gesture(
    PanGesture({ fingers: 1, distance: 1 })
      .onActionStart(() => {
        this.startYaw = this.camera.yaw;
        this.startPitch = this.camera.pitch;
        this.startPanX = this.camera.panX;
        this.startPanY = this.camera.panY;
      })
      .onActionUpdate((event: GestureEvent) => {
        if (this.toolMode === 'pan') {
          this.camera.panX = this.startPanX + event.offsetX;
          this.camera.panY = this.startPanY + event.offsetY;
        } else {
          this.camera.yaw = this.startYaw + event.offsetX * 0.008;
          this.camera.pitch = Math.max(-1.15, Math.min(1.15, this.startPitch + event.offsetY * 0.006));
        }
        this.draw();
      })
  )
  .parallelGesture(
    PinchGesture({ fingers: 2 })
      .onActionStart(() => {
        this.startZoom = this.camera.zoom;
      })
      .onActionUpdate((event: GestureEvent) => {
        this.setZoom(this.startZoom * event.scale);
      })
  )
  .onAxisEvent((event: AxisEvent) => {
    const delta = event.getVerticalAxisValue();
    this.setZoom(this.camera.zoom * Math.exp(-delta * 0.08));
  })

三个细节值得单独拎出来。

其一,getUIContext().vp2px(1) 把 vp 尺寸换算成物理像素。鸿蒙PC 常接高 DPI 显示器,如果直接用 vp 数值当作画布像素,绘制结果会偏小或者出现锯齿。源码用 new CanvasRenderingContext2D(this.settings, LengthMetricsUnit.PX) 明确把上下文单位设成 PX,再在 onAreaChange 里同步 widthPx / heightPx,这条链路是高分屏下不糊的关键。

其二,滚轮缩放用指数映射 zoom * exp(-delta * 0.08),并在 setZoom 里钳制到 [0.55, 2.4];俯仰角钳制到 [-1.15, 1.15]。指数映射让小幅滚动细腻、大幅滚动仍然可控,钳制则避免相机翻转或模型缩到不可见。这类「交互手感」参数在手机端通常不存在,属于鸿蒙PC 适配的典型增量。

其三,悬停态由通用按钮组件统一处理:

// entry/src/main/ets/components/StudioControls.ets : 11-29
@State private hovered: boolean = false;

build() {
  Stack() {
    AppIcon({
      iconColor: this.selected ? StudioColors.accent : this.hovered ? StudioColors.primaryText : StudioColors.secondaryText,
      name: this.icon,
      iconSize: this.iconSize,
      stroke: 1.65
    })
  }
  .width(this.buttonSize)
  .height(this.buttonSize)
  .backgroundColor(this.selected ? StudioColors.accentSoft : this.hovered ? StudioColors.hover : Color.Transparent)
  .borderRadius(8)
  .onHover((inside: boolean) => {
    this.hovered = inside;
  })
  .onClick(() => this.action())
}

onHover + @State hovered 让二十多个图标按钮共享同一套桌面反馈。把这类反馈抽到组件里,而不是散落在每个按钮上,是鸿蒙PC 界面保持一致性最省事的做法。窗口尺寸变化时,onAreaChange 只更新画布尺寸并重绘,相机状态保存在组件成员里不会被重置——这也是桌面用户拖窗口时不会「视角突然跳回默认」的原因。

从跨平台框架视角看,PanGesture、PinchGesture 这类跨端手势相对容易在 ArkUI-X 跨平台框架中保持一致;而 onAxisEvent(滚轮轴事件)、onHover(悬停态)、vp2px(高分屏换算)以及 deviceTypes: ['2in1'](桌面分发形态)都强依赖鸿蒙PC 的平台能力。若将来把界面层迁到 ArkUI-X 跨平台框架,这几个点应当通过平台桥接单独实现,而不是假设「一套代码自动具备桌面交互」。这也是本文反复强调的选型边界:跨平台框架降低的是界面复用成本,不降低桌面形态适配成本。


五、渲染核心:Canvas 2D 上的软件 Splat 管线

5.1 数据结构决定了能力上限

先看清渲染器操作的是什么。GaussianRenderer.ets 定义的点只有位置、尺寸、透明度、深度和三组预计算颜色:

// entry/src/main/ets/rendering/GaussianRenderer.ets : 1-32
export interface CameraState {
  yaw: number;
  pitch: number;
  zoom: number;
  panX: number;
  panY: number;
}

interface GaussianPoint {
  x: number;
  y: number;
  z: number;
  size: number;
  alpha: number;
  depth: number;
  color: string;
  normalColor: string;
  heatColor: string;
}

class SeededRandom {
  private seed: number;

  constructor(seed: number) {
    this.seed = seed;
  }

  next(): number {
    this.seed = (this.seed * 1664525 + 1013904223) % 4294967296;
    return this.seed / 4294967296;
  }
}

SeededRandom 是线性同余发生器,固定种子意味着每次进入页面生成的点云完全一致,旋转、缩放、切换着色模式时不会出现「模型闪一下变了」的跳变。对一个演示型鸿蒙PC 应用来说,确定性比随机性更重要;对真实 3DGS 来说则完全不够——真实高斯基元至少需要三维均值、协方差(通常由缩放与四元数旋转表达)、不透明度与球谐系数,还要配合按视锥剔除与分块光栅化。这里没有这些字段,因此它是「长得像高斯点云的 2D 绘制」,不是 3DGS 光栅化器。

5.2 用参数化旋转体「造」出三件文物

点云不是从文件加载的,而是按器形参数方程采样出来的。以青釉弦纹瓶为例:

// entry/src/main/ets/rendering/GaussianRenderer.ets : 94-124
private vaseRadius(t: number): number {
  if (t < 0.08) {
    return 0.48 + t * 2.2;
  }
  if (t < 0.36) {
    return 0.66 + Math.sin((t - 0.08) / 0.28 * Math.PI * 0.7) * 0.43;
  }
  if (t < 0.66) {
    return 1.02 - (t - 0.36) * 0.58;
  }
  if (t < 0.84) {
    return 0.85 - (t - 0.66) * 2.7;
  }
  return 0.36 + Math.pow((t - 0.84) / 0.16, 1.4) * 0.12;
}

private createVase(): void {
  const random = new SeededRandom(7319);
  for (let index = 0; index < 26500; index++) {
    const t = random.next();
    const theta = random.next() * Math.PI * 2;
    const flute = Math.sin(theta * 12 + t * 2) * 0.009;
    const rings = Math.sin(t * 94) * 0.008;
    const radius = this.vaseRadius(t) + flute + rings;
    const x = Math.cos(theta) * radius;
    const z = Math.sin(theta) * radius;
    const y = (t - 0.5) * 3.55;
    const key = Math.max(0, Math.cos(theta + 0.65)) * 0.72;
    const rim = Math.pow(t, 5) * 0.34;
    const groove = Math.max(0, Math.sin(t * 94)) * 0.12;
    this.addPoint(x, y, z, 0.012 + random.next() * 0.017, 0.34 + key + rim + groove, 0, random);
  }
}

五段分段函数 vaseRadius(t) 描述瓶底、腹、肩、颈、口的轮廓;sin(theta * 12 + t * 2) 叠出十二道弦纹,sin(t * 94) 叠出细密的拉坯环纹;key 用余弦模拟固定方向的主光,rim 提亮口沿,groove 强化凹处明暗。盏与兽分别由 createBowl()(约 27,100 点)和 createGuardian()(六个椭球组合,约 29,500 点)生成。每个器形约 2.7–3.0 万点,全部是运行时现算,没有任何模型文件——这解释了为什么 HAP 只有几百 KB,也解释了为什么界面上写着 1.24M Gaussians 却能在纯 CPU 软件绘制下流畅拖动:真实绘制的点数比文案少了一到两个数量级。

5.3 一帧是怎么画出来的

render() 的顺序是:清屏 → 径向渐变背景 → 透视地面网格 → 阴影椭圆 → 相机旋转求深度 → 深度排序 → 透视投影 → 逐点绘制椭圆 → 恢复 globalAlpha。

// entry/src/main/ets/rendering/GaussianRenderer.ets : 213-249
const cosineYaw = Math.cos(camera.yaw);
const sineYaw = Math.sin(camera.yaw);
const cosinePitch = Math.cos(camera.pitch);
const sinePitch = Math.sin(camera.pitch);
for (let index = 0; index < this.points.length; index++) {
  const point = this.points[index];
  const rotatedX = cosineYaw * point.x - sineYaw * point.z;
  const rotatedZ = sineYaw * point.x + cosineYaw * point.z;
  point.depth = sinePitch * point.y + cosinePitch * rotatedZ;
}
this.points.sort((first: GaussianPoint, second: GaussianPoint) => first.depth - second.depth);
for (let index = 0; index < this.points.length; index++) {
  const point = this.points[index];
  const rotatedX = cosineYaw * point.x - sineYaw * point.z;
  const rotatedZ = sineYaw * point.x + cosineYaw * point.z;
  const rotatedY = cosinePitch * point.y - sinePitch * rotatedZ;
  const perspective = 6 / Math.max(2.4, 6 - point.depth);
  const screenX = centerX + rotatedX * scale * perspective;
  const screenY = centerY - rotatedY * scale * perspective;
  const radius = Math.max(0.45, point.size * scale * perspective * pointScale);
  if (screenX < -radius || screenX > width + radius || screenY < -radius || screenY > height + radius) {
    continue;
  }
  context.fillStyle = mode === '法线' ? point.normalColor : mode === '置信度' ? point.heatColor : point.color;
  context.globalAlpha = Math.min(1, point.alpha * opacity);
  if (mode === '点云') {
    context.fillRect(screenX, screenY, Math.max(0.7, radius * 0.65), Math.max(0.7, radius * 0.65));
  } else {
    context.beginPath();
    context.ellipse(screenX, screenY, radius * 1.25, radius * 0.72,
      Math.atan2(rotatedY, rotatedX) * 0.28, 0, Math.PI * 2);
    context.fill();
  }
}
context.globalAlpha = 1;

几个设计点值得注意。三角函数只在每帧开头算一次,避免在数万次循环里反复求值;深度排序是画家算法(由远及近覆盖),用一次 sort 换取近似的遮挡关系;透视因子 6 / max(2.4, 6 - depth) 中的 max 防止相机贴近时分母趋零导致点被放大到失控;屏幕外的点用一次包围判断直接跳过;椭圆的长短轴按 1.25 : 0.72 拉伸,并用 atan2(rotatedY, rotatedX) * 0.28 做轻微旋转,视觉上更像有方向的 Splat。addPoint() 已经把物体色、法线色、置信度热力色预先算成 rgb(...) 字符串,因此循环里只做选择、不做字符串拼接,这是保证逐点绘制不掉帧的必要优化。

绘制调度则由组件负责,用 16 ms 的单次定时器把高频事件合并成一帧:

// entry/src/main/ets/components/GaussianViewport.ets : 71-81
private draw(): void {
  if (this.drawTimer >= 0) {
    return;
  }
  this.drawTimer = setTimeout(() => {
    this.drawTimer = -1;
    const density = this.getUIContext().vp2px(1);
    this.renderer.render(this.context, this.widthPx, this.heightPx, this.camera, this.variant,
      this.pointScale, this.splatOpacity, this.renderMode, density);
  }, 16);
}

PanGesture 的 onActionUpdate 在鼠标拖动时可能一帧内触发多次,这个「已有定时器就不再排新任务」的写法把多次失效请求合并为一次重绘;组件销毁时 aboutToDisappear() 会 clearTimeout,避免页面退出后仍有回调。四种着色模式(高斯 / 点云 / 法线 / 置信度)只改变 fillStyle 的选择与是否用 fillRect,不重新生成点云,因此切换非常轻。

对鸿蒙PC 而言,这套软件渲染的价值在于零依赖、零原生库、任何设备都能跑起来,非常适合做交互原型与布局验证;局限也很直接:三万点量级尚可,一旦真的要渲染百万级高斯基元,sort 的复杂度、逐点 ellipse 的路径开销、每点切换 fillStyle/globalAlpha 的状态切换都会成为瓶颈,必须转向 XComponent + GPU 光栅化。把这条边界写清楚,比含糊地说「已在鸿蒙PC 上实现端侧 3DGS」更诚实,也更有助于后来者选对技术路线。同样地,如果引入 ArkUI-X 跨平台框架,Canvas 2D 这类基础绘制能力通常属于跨端可复用部分,但 GPU 视口与设备侧推理仍应留在原生层,这与第四节得出的边界是一致的。


六、状态机与数据流:把「重建流程」做成可演示的前端闭环

鸿蒙PC 工作台的魅力在于多面板联动:点左侧项目,中间视口换模型,右侧指标刷新,底部流水线跟着变;拖动右侧 Slider,视口立刻改变 Splat 大小与透明度;点击优化按钮,进度条走完再弹一条 toast。这一切由 Index.ets 里的 16 个 @State 驱动,没有路由跳转、没有数据库、没有网络请求。

数据底座是 Models.ets 里的两个纯函数:

// entry/src/main/ets/common/Models.ets : 22-36 / 66-72
export function createProjects(): ReconstructionProject[] {
  return [
    {
      id: 1,
      name: '青釉弦纹瓶',
      collection: '宋代器物研究组',
      variant: 0,
      images: 186,
      gaussians: '1.24M',
      score: '94.6',
      resolution: '0.18 mm',
      updated: '刚刚更新',
      accent: '#72D9BE',
      duration: '08:42'
    }
    // id 2「影青刻花盏」、id 3「铜鎏金兽」结构相同
  ];
}

export function createPipeline(project: ReconstructionProject): PipelineStage[] {
  return [
    { index: '01', title: '影像对齐', detail: project.images.toString() + ' / ' + project.images.toString() + ' 张', state: '完成' },
    { index: '02', title: '稀疏重建', detail: '42.8K 特征点', state: '完成' },
    { index: '03', title: '高斯优化', detail: project.gaussians + ' Gaussians', state: '完成' },
    { index: '04', title: '质量评估', detail: project.score + ' 综合评分', state: '完成' }
  ];
}

createPipeline() 用当前项目的字段拼出底部四张卡片,所以切换项目时「186 / 186 张」「1.24M Gaussians」「94.6 综合评分」会同步变化。这是 ArkUI 声明式 UI 最典型的用法:数据是一个纯函数的结果,UI 只负责把结果画出来,不需要手工同步 DOM 式的节点。

日志页同样是一份静态数组:

// entry/src/main/ets/pages/Index.ets : 31-40
private readonly logLines: string[] = [
  '[21:08:42]  INFO   质量评估完成,综合得分 94.6 / 100',
  '[21:08:39]  INFO   合并 8 个局部高斯块,生成 1,248,632 个高斯',
  '[21:08:12]  INFO   第 30,000 次迭代完成,loss = 0.0048',
  '[21:06:54]  INFO   自适应密度控制:分裂 18.4K,克隆 9.2K,剪枝 3.1K',
  '[21:04:31]  INFO   NPU 混合精度优化已启用,峰值内存 3.8 GB',
  '[21:02:18]  INFO   COLMAP 位姿优化完成,186 / 186 张影像已注册',
  '[21:00:03]  INFO   本地特征提取完成,检测到 42,816 个稳定特征点',
  '[20:59:58]  INFO   创建端侧重建任务,所有数据仅在本机处理'
];

图 4 里那 8 行就是它。它模拟了真实重建管线的阶段术语,但本质上只是 ForEach 渲染的文本。右侧检查器里的三项指标同理,是三个字符串常量:

// entry/src/main/ets/components/InspectorPanel.ets : 107-111
Row() {
  this.metric('PSNR', '31.8', 'dB')
  this.metric('SSIM', '0.961', '')
  this.metric('LPIPS', '0.083', '')
}
.width('100%')

它们不会随项目、随优化进度改变。质量预设「流畅 / 平衡 / 精细」也只切换 0.60M / 1.25M / 2.00M 的预算文字并弹一条 toast,不改变点云数量——这解释了图 3 里切到「精细」后视口密度不变的观感。把这些「占位」明确标出来,是本文坚持的真实性底线;同时它也让工程边界变得清晰:读者要替换的是数据层与任务层,布局与交互层可以直接拿去用。

「开始增量优化」按钮背后的状态机如下:

// entry/src/main/ets/pages/Index.ets : 79-108
private startOrPauseOptimization(): void {
  if (this.isOptimizing && this.optimizingProjectId === this.selectedId) {
    this.isOptimizing = false;
    if (this.optimizeTimer >= 0) {
      clearInterval(this.optimizeTimer);
      this.optimizeTimer = -1;
    }
    this.notify('优化已暂停,当前检查点已保留');
    return;
  }
  if (this.isOptimizing && this.optimizingProjectId !== this.selectedId) {
    this.dialogMode = 'tasks';
    this.notify('已有重建任务正在运行');
    return;
  }
  if (this.optimizingProjectId !== this.selectedId) {
    this.optimizeProgress = 0;
  }
  this.optimizingProjectId = this.selectedId;
  this.isOptimizing = true;
  this.optimizeTimer = setInterval(() => {
    this.optimizeProgress = Math.min(100, this.optimizeProgress + 2);
    if (this.optimizeProgress >= 100) {
      clearInterval(this.optimizeTimer);
      this.optimizeTimer = -1;
      this.isOptimizing = false;
      this.notify('增量优化完成,质量报告已更新');
    }
  }, 120);
}

这段代码有三个值得学习的工程习惯。第一,用 optimizingProjectId 记录「哪个项目在跑」,从而区分「同项目暂停」「跨项目冲突」「新项目重新开始」三种情况;第二,Math.min(100, ...) 保证进度不越界;第三,达到 100 或暂停时都主动 clearInterval,并把句柄置回 -1,不留野定时器。桌面应用会话时间长,定时器泄漏比手机端更容易被察觉,这类清理不可忽视。

跨组件的参数刷新依赖 @Prop + @Watch:

// entry/src/main/ets/components/GaussianViewport.ets : 9-14 / 55-57
@Prop @Watch('onModelChanged') variant: number = 0;
@Prop @Watch('onRenderParamsChanged') pointScale: number = 1;
@Prop @Watch('onRenderParamsChanged') splatOpacity: number = 0.82;
@Prop gaussianCount: string = '1.24M';
@Prop showWeakAreas: boolean = false;
@Prop @Watch('onReset') resetToken: number = 0;

private onRenderParamsChanged(): void {
  this.draw();
}

Slider 的 onChange 改的是父组件的 @State,值沿 @Prop 下传,@Watch 回调再触发一次合并后的重绘。resetToken 是一个很实用的小技巧:切换项目时父组件只需让这个数字自增,子组件的 @Watch('onReset') 就知道要重置相机,而不必比较项目对象是否相等。类似的思路在 ArkUI-X 跨平台框架里同样成立,因为状态管理与组件通信属于声明式 UI 的通用能力,不依赖鸿蒙PC 特有 API。


七、ArkUI-X 跨平台框架:什么时候该上,边界在哪里

现在回到开篇那个问题。ArkUI-X 是 OpenHarmony 项目群技术指导委员会孵化的跨平台应用开发框架,它把 ArkUI 声明式 UI 从 OpenHarmony / HarmonyOS 扩展到 Android、iOS 等操作系统,目标是让开发者基于一套主代码构建多平台应用;其公开资料里强调的特征包括统一跨平台 API 能力集与扩展机制、高效的渲染管线与平台一致性渲染、以及动态化能力。本文把它作为「具体跨平台框架」的参照,并不是说配套工程已经接入了它——恰恰相反,配套工程是纯原生实现,因此更适合用来判断「哪些代码值得交给跨平台框架、哪些必须留在鸿蒙PC 原生层」。

先给一张按本工程代码事实归纳的选型矩阵(其中跨平台框架一侧为技术适配分析,不是本工程的跑分结果):

维度鸿蒙PC 原生 ArkTSArkUI-X 跨平台框架建议
设备形态声明deviceTypes: ['2in1'] 直接声明桌面分发需按目标平台分别配置与打包桌面优先、窗口行为复杂时保留原生配置
鼠标 / 悬停 / 高分屏onAxisEvent、onHover、vp2px 开箱可用需要平台桥接或按端判断把输入层收敛成统一接口,逐端实现
声明式界面与跨端写法一致@Component / @State / 布局可直接复用项目列表、检查器、对话框优先复用
纯逻辑与数据纯函数、状态机、伪随机生成器完全可复用抽到 common 层,避免依赖系统 Kit
高性能图形可走 XComponent / 原生 GPU 管线跨端一致性需额外验证渲染引擎保持平台原生,界面层才复用
包体与依赖本工程零三方依赖、约 482 KB HAP需引入框架运行时只做鸿蒙PC 单端时,原生更轻
团队与维护单端一套代码多端一套主代码 + 平台桥接端数越多,跨端收益越大

为了把「具体跨平台框架」这个维度说清楚,再放一张中性的方案对照。下表只讨论技术适配关系,不涉及任何产品推荐:

方案与鸿蒙PC 的关系本文中的角色
ArkUI-X 跨平台框架与 ArkTS / ArkUI 同源,声明式界面层复用成本最低主参照
Flutter / React Native自带渲染或控件桥接,桌面滚轮、悬停、窗口断点仍需单独适配技术参照
Qt / Electron桌面生态成熟,但与 ArkUI 的声明式体系属于不同技术栈技术参照
Kotlin Multiplatform / Compose Multiplatform逻辑层或 UI 层跨端,平台能力依旧要逐端落地技术参照

结论可以浓缩成一句话:跨平台框架的价值随目标操作系统数量增长,而桌面形态适配的成本随窗口与输入复杂度增长,两者是两条独立的轴。如果产品只面向鸿蒙PC,原生 ArkTS 在包体、平台能力调用和调试链路上都更直接;如果还要覆盖 Android / iOS,且主要工作量在表单、列表、检查器这类界面上,那么把声明式界面层交给 ArkUI-X 跨平台框架是划算的;但如果核心工作量在三维视口、GPU 光栅化、端侧推理与系统级文件能力上,那么跨平台框架能复用的只是外壳,收益有限。

基于上面的分层图,给出一条可执行的迁移顺序。第一步,把不依赖任何系统 Kit 的纯逻辑抽到 common(layoutModeOf、SeededRandom、数据模型、状态机都属于这一类)。第二步,把界面组件按「是否触碰平台 API」分类:项目侧栏、检查器、对话框、流水线卡片属于可复用界面;Canvas 上下文创建、vp2px、onAxisEvent、deviceTypes 属于平台相关。第三步,为平台相关能力定义窄接口,例如「注册滚轮回调」「查询像素密度」「触发重绘」「安装 HAP 并签名」,鸿蒙PC 侧用原生实现,其他端各自实现。第四步,把渲染引擎留在原生层,界面层只通过状态与回调交互,避免把 CanvasRenderingContext2D 之类的具体类型泄漏进跨端代码。第五步,在鸿蒙PC 设备上做回归:拖动窗口跨越 1180 / 950 两个阈值、滚轮缩放、悬停态、高分屏缩放,确认平台桥接没有丢失桌面行为。

这里还有一个容易被忽略的协作细节:源码托管。本文配套工程目前是本地工程,若要把改造版本开源,建议统一托管到 AtomGit,并通过开源鸿蒙PC 社区在 AtomGit 上申请新建项目,让 issue、PR、构建脚本与截图素材集中归档;不要把同一份源码分散到名称不一致的多个托管位置,否则读者很难判断哪个版本对应文中的截图。发布文章时给出一个明确的 AtomGit 仓库地址,并在仓库中记录设备信息与截图版本,社区内被检索、被引用、被复现时才不会出现歧义。


八、性能与工程化:让三万点在鸿蒙PC 上画得稳

鸿蒙PC 的大屏与高 DPI 会放大 CPU 绘制的成本:物理像素更多、画布更大、窗口拉宽后一帧要填的点也更多。把本工程的瓶颈按影响排序,可以做一张清单:

瓶颈代码位置影响优化方向
每帧 points.sort()GaussianRenderer.ets:224数万点的 O(n log n) 比较相机不变时缓存排序结果,仅在 yaw / pitch 变化后重排
逐点 ellipse 路径GaussianRenderer.ets:242每点一次 beginPath + fill按颜色分桶批量绘制,或用离屏缓存复用静态视角
逐点切换 fillStyle / globalAlphaGaussianRenderer.ets:237-238大量状态切换预计算颜色字符串(源码已做),按 alpha 分档减少切换
两次循环重复计算旋转GaussianRenderer.ets:217-229旋转矩阵算两遍第一轮把 rotatedX/Y/Z 缓存进点结构,第二轮直接取值
高 DPI 画布尺寸GaussianViewport.ets:104-109像素面积随密度平方增长交互期间动态降采样,静止后恢复全分辨率
高频手势事件GaussianViewport.ets:118-127一帧多次触发重绘用脏标记合并请求(源码已用 16 ms 定时器合并)

源码里的 16 ms 节流还可以再抽象一层,把「失效请求合并 + 脏标记 + 可取消」做成通用工具类,方便界面层与渲染层共用。下面这段代码不依赖任何系统 Kit,可直接放进 ets/rendering/ 并在组件中调用:

export class FrameScheduler {
  private timer: number = -1;
  private dirty: boolean = false;
  private readonly intervalMs: number;
  onFrame: () => void = () => {};

  constructor(intervalMs: number = 16) {
    this.intervalMs = intervalMs;
  }

  invalidate(): void {
    this.dirty = true;
    if (this.timer >= 0) {
      return;
    }
    this.timer = setTimeout(() => {
      this.timer = -1;
      if (this.dirty) {
        this.dirty = false;
        this.onFrame();
      }
    }, this.intervalMs);
  }

  cancel(): void {
    if (this.timer >= 0) {
      clearTimeout(this.timer);
      this.timer = -1;
    }
    this.dirty = false;
  }
}

调用方式很直接:在 aboutToAppear() 里把 onFrame 指向真正的绘制函数,在手势回调、Slider 回调、onAreaChange 里只调用 invalidate(),在 aboutToDisappear() 里调用 cancel()。这样无论一帧内来了多少次事件,最多只画一次;页面退出时也不会留下悬空回调。

想量化优化效果,建议不要凭肉眼判断「变流畅了」,而是加一层最小测量:在 render() 前后取时间戳,把耗时写入环形数组,再算均值与 P95。下面这个纯逻辑类可以直接使用:

export class RenderTimer {
  private samples: number[] = [];

  begin(): number {
    return Date.now();
  }

  record(startMs: number): number {
    const cost = Date.now() - startMs;
    this.samples.push(cost);
    if (this.samples.length > 200) {
      this.samples.shift();
    }
    return cost;
  }

  percentile(p: number): number {
    if (this.samples.length === 0) {
      return 0;
    }
    const sorted = this.samples.slice().sort((a: number, b: number) => a - b);
    const index = Math.min(sorted.length - 1, Math.floor(sorted.length * p));
    return sorted[index];
  }
}

本文没有给出「优化前多少帧、优化后多少帧」的具体数字,因为那需要在同一台鸿蒙PC 设备、同一窗口尺寸、同一缩放级别下多轮受控测量才有意义;凭一次截图编造性能数据既不真实,也会误导读者。更稳妥的做法是把上面的 RenderTimer 接进 draw(),配合 hilog 输出,按「窗口 1440×900 / 缩放 100% / 点尺寸 1.00× / 不透明度 82%」这样的固定口径记录多轮数据,再比较改动前后的均值与 P95。这个口径本身比某个漂亮的数字更有复用价值。

最后强调一点:这些优化都建立在「Canvas 2D 软件绘制」的前提上。当点数从三万增长到百万级,或者需要真实 3DGS 的协方差投影与 α 混合时,应该把视口迁移到 XComponent 承载的 GPU 管线,界面层继续保持 ArkUI 声明式结构;此时 ArkUI-X 跨平台框架仍可复用外壳,但渲染与推理必须在鸿蒙PC 原生侧实现。这条分工在第四节、第五节、第七节反复出现,正是本文想留给读者的核心判断。


九、从原型走向真实 3DGS 管线:一条可落地的路线图

如果读者想把这个鸿蒙PC 工作台继续做成能产出真实模型的工具,建议按下面六个阶段推进。每个阶段都尽量只替换一层,保持第四、五、六节已经验证过的界面与状态机不变,这样回归成本最低。

阶段一:影像接入与工程目录。 用系统文件选择能力把多视角影像导入应用沙箱,建立「项目 ID / 影像列表 / 输出目录」的目录约定,并校验数量、分辨率与 EXIF 完整性。当前 Models.ets 里的 images 字段可以直接升级为真实列表长度,createPipeline() 的第一张卡片也就从静态字符串变成真实进度。

阶段二:位姿估计与稀疏重建。 用 SfM 类流程(工程界面里写的是 COLMAP)估计每张影像的相机内外参并得到稀疏点云,输出一个可供后续初始化高斯基元的场景文件。这一步计算量大、耗时长,应放在独立任务或工作线程中,通过事件把「已注册影像数 / 特征点数」回传给现有状态机,而不是在 UI 线程里同步执行。

阶段三:高斯基元优化。 把稀疏点初始化为带协方差、不透明度与球谐系数的高斯基元,迭代优化并执行自适应密度控制(分裂、克隆、剪枝)。端侧推理或 NPU 加速如果可用,应封装成独立能力模块,通过统一接口暴露「开始 / 暂停 / 进度 / 检查点」,再替换 startOrPauseOptimization() 里的 setInterval。这一步做完,界面上 第 30,000 次迭代完成,loss = 0.0048 才从文案变成真实日志。

阶段四:视口升级。 保留 GaussianViewport.ets 的相机、手势、滚轮与 HUD,把 CanvasRenderingContext2D 的逐点椭圆绘制替换为 XComponent 承载的 GPU 光栅化;GaussianPoint 需要扩展为完整的高斯基元结构。第四节讲的输入适配、第五节讲的节流策略、第八节讲的测量口径都可以继续沿用,只是渲染后端换了。

阶段五:真实质量评估。 用保留视角渲染结果与真值影像计算 PSNR、SSIM、LPIPS,并把结果写回 InspectorPanel,替换 31.8 / 0.961 / 0.083 三个常量。质量环的分数也应该由评估函数产生,而不是读 Models.ets 里的字面量。薄弱区域标记可以基于视角覆盖率与置信度做实际统计,替换当前的固定气泡。

阶段六:导出与协作。 按 PLY / SPZ / 3DGS 等格式写出场景,记录相机坐标系(界面上写的是 Y-up · 米制)与参数版本,把项目源码、构建脚本、设备截图与测量数据一起归档到 AtomGit 仓库。开源鸿蒙PC 社区的项目通常需要可复现的素材,完整桌面截图与固定口径的测量表格比一句「运行流畅」更有说服力。

整条路线里,ArkUI-X 跨平台框架的定位始终一致:项目侧栏、检查器、对话框、流水线卡片、纯逻辑工具属于跨端可复用层;2in1 声明、鼠标轴事件、悬停、高分屏换算、HAP 签名、GPU 视口与端侧推理属于鸿蒙PC 原生层。先在鸿蒙PC 上把原生层跑通,再按业务需要把界面层迁到跨平台框架,比反过来「先选框架再补平台能力」风险低得多。文物影像往往涉及馆藏数据,全流程坚持本地处理、数据不出本机,也更容易通过协作方的合规审查。


十、复现清单与十条踩坑记录

10.1 在鸿蒙PC 上复现本文截图

  1. 准备与工程匹配的 DevEco Studio 环境,build-profile.json5 中 compatibleSdkVersion / targetSdkVersion 为 5.0.5(17),runtimeOS 为 HarmonyOS。
  2. 用 DevEco 打开 Gaussian_ohos 根目录,等待 hvigor 同步完成。因为 oh-package.json5 的 dependencies 为空,不需要额外安装第三方 ohpm 依赖。
  3. 检查 entry/src/main/module.json5 的 deviceTypes 为 ['2in1'],并在「项目结构 → 签名配置」里确认调试证书有效(图 5 显示的就是这个面板)。签名材料路径与本机账号绑定,换机器后需重新配置。
  4. 连接鸿蒙PC 设备或可用的 2in1 目标,执行构建并生成 entry-default-signed.hap;也可用命令行确认设备在线后安装:
hdc list targets
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap
  1. 运行应用,按图 2 到图 4 依次验证:切换「影青刻花盏 / 青釉弦纹瓶」、拖动视口旋转、滚轮缩放、开启薄弱区域标记、拖动点尺寸与不透明度 Slider、切到「重建日志」页签、点击「开始增量优化」看进度与 toast。
  2. 把应用窗口拖窄跨过 1180 与 950 两个阈值,确认左栏、右栏依次收起而相机视角不重置(对应图 6)。
  3. 截图时保留完整桌面:底部 Dock、系统状态栏、DevEco 窗口与应用窗口同屏,这样读者一眼就能确认运行平台是鸿蒙PC;随后把图片上传到图床再嵌入文章,避免本地路径在社区里失效。

如果需要把改造后的版本分享给社区,建议在 AtomGit 新建仓库时同步放上 README、签名注意事项、设备信息、截图原图与测量口径说明,并在开源鸿蒙PC 社区提交项目申请,方便他人按同一口径复现。

10.2 十条踩坑记录

#现象 / 风险原因处理
1手机或普通平板装不上 HAPmodule.json5 只声明 2in1明确目标设备形态;需要多端时补齐 deviceTypes
2高分屏上点云发虚、位置偏移把 vp 当物理像素用LengthMetricsUnit.PX + vp2px(1) 换算画布尺寸
3拖窗口后视角跳回默认在尺寸回调里重建了相机对象相机状态保存在组件成员中,尺寸变化只重绘
4改 Slider 后画面不刷新@Watch 只改了状态、没触发绘制在 @Watch 回调里显式调用 draw()
5拖动时明显掉帧一帧内多次排重绘、每帧都排序16 ms 合并请求;相机不变时缓存深度顺序
6把 31.8 / 0.961 / 0.083 当成评测结论三项指标是字符串常量用真实渲染结果与真值影像计算后再引用
7页面退出后仍有回调setInterval / setTimeout 未清理aboutToDisappear() 中 clearInterval / clearTimeout
8误以为 *.msgpack、*.protoBin 是模型权重它们是 ArkTS 编译器缓存只把 entry/src 视为源码;构建产物不入仓
9期待「NPU 加速」带来性能变化无 C++、无 NAPI、无 .so、无推理调用需要时按第九节阶段三单独接入能力模块
10换机器后安装失败调试签名材料路径与本机绑定重新配置签名并确认 bundleName 一致
11迁移到 ArkUI-X 跨平台框架后滚轮失效桌面输入能力未做平台桥接把滚轮、悬停、像素密度收敛成窄接口逐端实现
12窗口变窄后侧栏「消失」被当成 bug1180 / 950 手写断点的设计行为用 layoutModeOf 统一判定,并在帮助里说明

这些坑大多不是算法问题,而是鸿蒙PC 桌面形态带来的工程细节:设备形态、像素单位、输入事件、窗口生命周期、签名材料。它们零散却决定了一个应用「能不能在真机上稳定跑起来」。把它们记在表格里,比记一段漂亮但无法复现的性能数字更有长期价值。


十一、总结

本文以「澜境 · Gaussian Studio」这个零依赖、零 .so 的鸿蒙PC 原生工程为样本,逐层拆解了面向 2in1 设备形态的窗口断点(1180 / 950)、鼠标滚轮与悬停交互、vp2px 高分屏换算、Canvas 2D 软件 Splat 渲染管线,以及由 16 个 @State 驱动的多面板状态机;同时用四张鸿蒙PC 设备完整桌面截图与两张自绘示意图,把「界面长什么样」和「代码怎么跑」对应起来。

更重要的是,文章始终没有回避两条边界。第一条是事实边界:界面上的百万级高斯数、PSNR / SSIM / LPIPS、NPU 加速与重建日志都是演示占位数据,视口里真正绘制的是约三万点确定性点云;把它当成真实 3DGS 引擎会得出错误结论。第二条是选型边界:ArkUI-X 跨平台框架擅长复用声明式界面与纯逻辑,而 2in1 声明、桌面输入、高分屏换算、GPU 视口与端侧推理必须留在鸿蒙PC 原生层。理解这两条边界,读者既能把本文的布局、交互、状态管理代码直接拿去用,也能按第九节的路线把它逐步升级成真正的端侧重建工具。

如果你正在为开源鸿蒙PC 生态做技术选型,希望这篇「把原型讲透、把边界讲明」的文章能少让你走一段弯路;也欢迎在 AtomGit 上提交你的复现结果与改进实现,让社区里的每一张运行截图都有对应的代码版本与测量口径。

检索关键词:鸿蒙PC、鸿蒙 PC、开源鸿蒙PC、ArkUI-X 跨平台框架、跨平台框架选型、ArkTS、ArkUI 声明式 UI、Canvas 2D、3D Gaussian Splatting、2in1 桌面形态、AtomGit。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐