【open harmony/harmonyos】ArkTS 实现 3D 透视投影:让普通组件拥有空间感

【open harmony/harmonyos】ArkTS 实现 3D 透视投影:让普通组件拥有空间感

前言 🌌

在 HarmonyOS / OpenHarmony 应用中,如果想做 3D 效果,很多人第一反应可能是使用 3D 引擎。

但如果需求只是“让节点有空间感”,其实不一定要上复杂引擎。对于轻量级知识图谱、星图、关系网络这类场景,可以用 ArkTS 数学计算 + ArkUI 普通组件实现一个简化版 3D 透视效果。

这篇文章会结合我的项目星图 Xingtu,分享如何用 ArkTS 实现 3D 坐标旋转、透视投影、节点缩放、深度排序,让普通 ArkUI 组件呈现出空间层次。✨

一、目标效果

项目中希望实现这样一个星图界面:

  • 节点分布在 3D 空间里
  • 拖动时节点会围绕视角旋转
  • 近处节点更大、更亮
  • 远处节点更小、更淡
  • 近处节点覆盖远处节点
  • 整体看起来像一个可探索的数据空间

重点是:这些节点本质上仍然是 ArkUI 组件,不是复杂 3D 模型。

二、定义 3D 坐标和投影结果

首先定义一个三维坐标类型:

exportinterfaceVec3 {x:number;y:number;z:number; }

节点保存在真实的 3D 坐标中:

exportinterfaceXingtuNode{ id:string; title:string; note:string; tags:string[]; position: Vec3; }

经过投影后,节点会变成屏幕上的二维位置:

exportinterfaceProjectedNode {id:string;title:string;note:string;tags:string[];screenX:number;screenY:number;scale:number;opacity:number;depth:number; }

这里的ProjectedNode是 UI 真正需要的数据:

  • screenX:屏幕横坐标
  • screenY:屏幕纵坐标
  • scale:节点大小比例
  • opacity:节点透明度
  • depth:节点深度

三、相机状态设计 📷

为了让用户可以旋转和缩放星图,需要一个相机状态。

exportinterfaceCameraState { yaw:number; pitch:number; distance:number;scale:number; }

项目中的默认相机如下:

exportfunctiondefaultCamera(): CameraState {return{yaw: -18, pitch: -10, distance:620, scale:1}; }

每个字段的含义:

  • yaw:水平旋转角度
  • pitch:垂直旋转角度
  • distance:相机和节点空间的距离
  • scale:整体缩放比例

有了相机状态,就可以通过改变相机,而不是直接改变所有节点位置,来实现视角变化。

四、坐标旋转计算

3D 投影的第一步,是根据相机角度旋转节点。

constDEGREE = Math.PI /180;exportfunctionrotatePoint(point: Vec3, yaw: number, pitch: number): Vec3 {constyawRad: number = yaw * DEGREE;constpitchRad: number = pitch * DEGREE;constyawX: number = point.x * Math.cos(yawRad) + point.z * Math.sin(yawRad);constyawZ: number = -point.x * Math.sin(yawRad) + point.z * Math.cos(yawRad);constpitchY: number = point.y * Math.cos(pitchRad) - yawZ * Math.sin(pitchRad);constpitchZ: number = point.y * Math.sin(pitchRad) + yawZ * Math.cos(pitchRad);return{ x: yawX, y: pitchY, z: pitchZ }; }

这段逻辑分两步:

  1. 根据yaw做水平旋转
  2. 根据pitch做上下旋转

用户拖动屏幕时,实际更新的是yawpitch,然后所有节点重新计算屏幕位置。

五、透视投影核心算法 🔵

坐标旋转后,需要把 3D 坐标投影到 2D 屏幕。

constCAMERA_FOCAL =560; export functionprojectNode( node: XingtuNode,camera: CameraState, viewport: ViewportSize ): ProjectedNode {constrotated: Vec3 =rotatePoint(node.position,camera.yaw,camera.pitch);constdepth: number =camera.distance- rotated.z;constperspective: number = CAMERA_FOCAL / Math.max(220, depth);consthalfWidth: number = viewport.width/2;consthalfHeight: number = viewport.height/2;return{ id: node.id, title: node.title, note: node.note, tags: node.tags,screenX: halfWidth + rotated.x*perspective*camera.scale,screenY: halfHeight + rotated.y*perspective*camera.scale,scale:perspective*camera.scale, opacity: Math.max(0.28, Math.min(1,0.2+perspective*0.35)), depth }; }

这里最关键的是perspective

constperspective: number = CAMERA_FOCAL / Math.max(220, depth);

depth越小,说明节点越靠近用户,perspective越大,节点显示就越大。

depth越大,说明节点越远,perspective越小,节点显示就越小。

这就是透视感的来源。

六、让远近影响透明度

除了大小,透明度也可以用来强化空间感。

opacity: Math.max(0.28, Math.min(1,0.2+ perspective *0.35))

这段代码限制了透明度范围:

  • 最低不低于0.28
  • 最高不超过1
  • 近处节点更亮
  • 远处节点更淡

如果只改变大小,不改变透明度,空间感会弱一些。大小 + 透明度一起变化,效果会更自然。

七、节点深度排序

在 3D 空间中,近处节点应该盖住远处节点。

项目中在 Store 里对投影节点做了排序:

projectedNodes(viewport: ViewportSize): ProjectedNode[] {returnthis.nodes .map((node: XingtuNode)=>projectNode(node, this.camera, viewport)) .sort((left: ProjectedNode, right: ProjectedNode)=>right.depth - left.depth); }

排序后,远处节点先渲染,近处节点后渲染。

Stack里,后面的组件会覆盖前面的组件,所以这样就能模拟基本的深度遮挡。

八、渲染节点组件

投影完成后,节点组件只关心自己的屏幕位置、大小和透明度。

privatenodeSize(): number {returnMath.max(30, Math.min(108,58*this.node.scale)); }privatenodePosX(): number {returnthis.node.screenX -this.nodeSize() /2; }privatenodePosY(): number {returnthis.node.screenY -this.nodeSize() /2; }

渲染时:

Stack() {} .width(this.nodeSize()) .height(this.nodeSize()) .borderRadius(this.nodeSize() /2) .backgroundColor(this.selected ? XingtuTheme.primaryAction : XingtuTheme.accent) .opacity(this.selected ?0.98:this.node.opacity *0.82) .shadow({ radius:this.selected ?30:12+this.node.scale *5, color:this.selected ? XingtuTheme.harmonyLightShadow :'#3493C5FD', offsetX:0, offsetY:this.selected ?0:4})

这里节点本质上就是一个带圆角和阴影的 ArkUI 组件,但因为它的位置、大小、透明度都来自 3D 投影,所以看起来就有了空间感。

九、监听视口尺寸变化

投影计算需要知道当前屏幕宽高。

项目中使用onAreaChange获取视口尺寸:

.onAreaChange((_,area)=> { this.viewportWidth =Number(area.width); this.viewportHeight =Number(area.height); })

然后在计算投影时传入:

this.store.projectedNodes({ width:this.viewportWidth, height:this.viewportHeight });

这样不同设备尺寸下,星图都可以以屏幕中心为基准进行布局。

十、拖动时更新相机

当用户单指拖动时,更新相机角度:

this.store.updateCamera(deltaX *0.42, deltaY *0.28);this.refreshScene();

Store 中的实现:

updateCamera(deltaYaw: number, deltaPitch: number): void {this.camera = { yaw:this.camera.yaw + deltaYaw, pitch: clampPitch(this.camera.pitch + deltaPitch), distance:this.camera.distance, scale:this.camera.scale }; }

垂直角度需要限制:

exportfunctionclampPitch(nextPitch:number):number{returnMath.max(-80,Math.min(80, nextPitch)); }

如果不限制pitch,用户可能把场景翻转到不舒服的角度。

十一、缩放时更新 scale

双指缩放最终修改的是camera.scale

updateScale(nextScale: number): void {this.camera = { yaw: this.camera.yaw, pitch: this.camera.pitch, distance: this.camera.distance, scale: Math.max(0.6, Math.min(2.2, nextScale)) }; }

这里限制缩放范围在0.62.2之间。

适当限制交互范围,可以避免用户把节点放大到失控,或者缩小到完全看不清。

十二、总结 🌟

这篇文章分享了如何在 HarmonyOS / OpenHarmony 中用 ArkTS 和 ArkUI 普通组件实现轻量级 3D 透视投影。

核心步骤是:

  • Vec3保存节点三维坐标
  • CameraState保存相机旋转和缩放
  • rotatePoint计算旋转后的坐标
  • projectNode把 3D 坐标转换成屏幕坐标
  • scaleopacity表现远近关系
  • depth排序模拟遮挡关系
  • 用 ArkUI 组件渲染节点

这种方案不适合重型 3D 游戏,但非常适合知识星图、关系网络、AI 概念图、灵感空间等轻量级场景。

不用复杂引擎,也能让普通组件拥有空间感。✨