HarmonyOS 7从 2D 到 3D:空间计算带来的到底是什么范式变革
HarmonyOS 7(API 26)里,空间计算第一次被单独拎出来当作一个板块,而不是塞在图形或媒体能力里顺手提一句。很多人第一反应是"UI 能转起来了""能做 3D 卡片了",但如果只把它当成视觉增强,就会漏掉真正难的部分:我们过去十年熟练的这套 2D 设计语言,在 Z 轴出现以后,有一半的默认假设都不成立了。
这篇文章想聊清楚两件事:系统级能力到底把哪些门槛抹平了,以及为什么"认知门槛"和"设计门槛"还在原地等着你。
系统把技术门槛按了下去,但没按掉认知门槛
先看系统这次到底给了什么。以前要在 App 里做真实的 3D 效果,你得自己引入渲染引擎、自己算透视矩阵、自己处理光照模型。ArkUI 现在把这些能力做进了声明式组件里:
- 图形变换:
rotate(带x/y/z旋转轴)、translate(支持z)、scale、perspective(视距)在一套属性链里就能组合出三维效果; - 真正的三维变换:从 API 20 起提供
transform3D,可以直接喂一个 4x4 矩阵,处理带透视的变换; - 场景级 3D:ArkGraphics 3D 通过
Component3D渲染 glTF 模型,相机、光源、材质都在 ArkTS 侧管理; - 端侧重建:Spatial Recon Kit 支持加载 3DGS 模型(MP4 / PLY / GLB);
- 空间音频:AudioKit 的
AudioSpatializationManager可以查询和订阅设备的空间音频渲染状态。
矩阵推导、投影、着色这些过去劝退设计师和大部分应用开发者的东西,现在被封装成了属性。这是"技术门槛下降"的真实含义。
但门槛下降的是"实现",不是"判断"。系统不会告诉你一个按钮该浮在前景还是沉到背景,不会告诉你把导航放到用户左侧 30 度意味着什么,也不会告诉你某个动效会不会让人头晕。能不能跑 是工程问题,该不该这么跑是设计问题------后者才是空间计算真正的分水岭。
从 X/Y 到 X/Y/Z,改的不是多一个轴
把 2D 界面往 Z 轴一推,很多人以为只是"多了个维度"。实际变动的是整套坐标语义。
在 2D 里,屏幕的 X/Y 是位置 ,深度没有概念。控件要么在,要么不在;层叠靠 zIndex 做遮挡排序,那只是绘制顺序,用户感知不到"远近"。
到了空间里,Z 轴同时承担了四种含义:
- 遮挡关系------近的挡住远的,这是最表层的物理直觉;
- 距离感------元素离观察者多远,直接对应"重要程度"和"可交互程度";
- 信息层级------前景是主任务,中景是上下文,背景是环境;
- 注意力资源------人眼在空间中一次只能聚焦一个焦平面,深度天然就是注意力分配器。
也就是说,Z 轴不再是渲染参数,而是一种信息组织手段。这是范式变革里最需要先扭转的一点:平面设计里的"上/下、左/右"对应的是阅读顺序,空间设计里的"近/远"对应的是认知优先级。
同一张卡片,2D 和空间两种写法
下面用一个商品卡片做对照。2D 版本是常见的平铺卡片;空间版本把同一张卡片放进一个有纵深的 Stack 里,用 perspective 和 z 位移拉开层次。
typescript
import { curves } from '@kit.ArkUI';
@Entry
@Component
struct CardParadigmDemo {
@State private spatial: boolean = false;
@Builder
ProductCard(title: string, price: string) {
Column({ space: 6 }) {
// 占位图,实际项目替换为 Image
Row()
.width('100%')
.aspectRatio(1)
.backgroundColor('#E8EDF5')
.borderRadius(12)
Text(title).fontSize(15).fontWeight(FontWeight.Medium).maxLines(1)
Text(price).fontSize(13).fontColor('#E85D3D')
}
.padding(10)
.width(150)
.backgroundColor(Color.White)
.borderRadius(16)
.shadow({ radius: 12, color: '#14000000', offsetY: 4 })
}
build() {
Column() {
// Stack 是空间感的起点:子组件在这里共享同一块画布
Stack({ alignContent: Alignment.Center }) {
this.ProductCard('旧旅行箱', '¥429')
.zIndex(0)
.translate({ x: this.spatial ? -70 : -50, y: 0, z: this.spatial ? -60 : 0 })
.rotate({ x: 0, y: 1, angle: this.spatial ? -18 : 0, perspective: 900 })
.opacity(this.spatial ? 0.75 : 1)
this.ProductCard('主推商品', '¥199')
.zIndex(2)
.translate({ x: 0, y: this.spatial ? 6 : 0, z: this.spatial ? 20 : 0 })
.rotate({ x: 0, y: 1, angle: this.spatial ? -4 : 0, perspective: 900 })
.scale({ x: this.spatial ? 1.12 : 1, y: this.spatial ? 1.12 : 1 })
this.ProductCard('新款上市', '¥359')
.zIndex(1)
.translate({ x: this.spatial ? 70 : 50, y: 0, z: this.spatial ? -60 : 0 })
.rotate({ x: 0, y: 1, angle: this.spatial ? 16 : 0, perspective: 900 })
.opacity(this.spatial ? 0.75 : 1)
}
.width('100%')
.height(320)
Button(this.spatial ? '回到平面' : '进入空间')
.margin({ top: 24 })
.onClick(() => {
// 一次性把三张卡片的位移/旋转/缩放补间到目标状态
this.getUIContext()?.animateTo(
{ duration: 400, curve: curves.springMotion(0.8, 20) },
() => { this.spatial = !this.spatial; }
);
})
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
这段代码里,spatial 这个布尔量切换的不是"有没有动画",而是三张卡片在 Z 轴上的相对关系:主推商品被拉到 z: 20 并放大,两侧商品退到 z: -60 并降低不透明度。perspective: 900 提供视距------值越小透视越夸张,值越大越接近正交投影。想深入的话,perspective 和 centerZ 组合还能做出"绕自己所在平面翻转"的效果。
这个例子里,真正决定信息层级的不是颜色也不是字号,而是 z 位置的相对排序。这就是空间 UI 和 2D UI 最本质的区别。
2D UI 与空间 UI 的设计要素对照
把设计要素拆开看,差异更直观:
| 设计要素 | 2D 平面 UI | 空间 UI | 变化实质 |
|---|---|---|---|
| 信息呈现 | 靠网格、留白、分区组织,信息量受单屏面积限制 | 靠距离、遮挡、景深组织,信息可分层堆叠在纵深上 | 从"平面排布"变成"纵深编排" |
| 导航 | 固定坐标系,页面跳转即视角切换 | 视角本身可移动,导航可以是"走过去"或"拉近看" | 从"切换页面"变成"移动观察点" |
| 反馈 | 颜色变化、缩放、位移,幅度小且以像素为单位 | 可叠加深度弹出、材质高光、空间音效、轻微透视偏移 | 反馈通道从 1 个扩到 3 个以上 |
| 状态 | 选中/禁用/加载用样式区分 | 除样式外,还需区分"在视野内/外""可触及/过远" | 状态维度与用户姿态相关 |
| 层级 | zIndex 只决定绘制顺序 |
Z 位置决定真实远近,参与遮挡与透视 | 层级从"视觉排序"变成"空间事实" |
| 交互输入 | 点击、滑动、长按(触点即目标) | 视线、手势、头部姿态协同,目标需要"选中"过程 | 输入从"直接点"变成"先瞄准再动作" |
这张表里最容易被低估的是最后两列。层级和交互输入这两栏,直接决定了 2D 那套"所见即所点"的直觉不能照搬。
案例:设置中心从列表变成分层空间
拿最常见的设置中心举例。2D 版本是一列设置项,用户滚动、点进去、返回。空间化之后,可以让"一级入口"作为前景层平铺,"二级详情"沉到中景,环境/预览作为背景层。
#mermaid-svg-8UD1i13g8P6Kktdf{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-8UD1i13g8P6Kktdf .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-8UD1i13g8P6Kktdf .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-8UD1i13g8P6Kktdf .error-icon{fill:#552222;}#mermaid-svg-8UD1i13g8P6Kktdf .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-8UD1i13g8P6Kktdf .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-8UD1i13g8P6Kktdf .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-8UD1i13g8P6Kktdf .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-8UD1i13g8P6Kktdf .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-8UD1i13g8P6Kktdf .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-8UD1i13g8P6Kktdf .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-8UD1i13g8P6Kktdf .marker{fill:#333333;stroke:#333333;}#mermaid-svg-8UD1i13g8P6Kktdf .marker.cross{stroke:#333333;}#mermaid-svg-8UD1i13g8P6Kktdf svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-8UD1i13g8P6Kktdf p{margin:0;}#mermaid-svg-8UD1i13g8P6Kktdf .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-8UD1i13g8P6Kktdf .cluster-label text{fill:#333;}#mermaid-svg-8UD1i13g8P6Kktdf .cluster-label span{color:#333;}#mermaid-svg-8UD1i13g8P6Kktdf .cluster-label span p{background-color:transparent;}#mermaid-svg-8UD1i13g8P6Kktdf .label text,#mermaid-svg-8UD1i13g8P6Kktdf span{fill:#333;color:#333;}#mermaid-svg-8UD1i13g8P6Kktdf .node rect,#mermaid-svg-8UD1i13g8P6Kktdf .node circle,#mermaid-svg-8UD1i13g8P6Kktdf .node ellipse,#mermaid-svg-8UD1i13g8P6Kktdf .node polygon,#mermaid-svg-8UD1i13g8P6Kktdf .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-8UD1i13g8P6Kktdf .rough-node .label text,#mermaid-svg-8UD1i13g8P6Kktdf .node .label text,#mermaid-svg-8UD1i13g8P6Kktdf .image-shape .label,#mermaid-svg-8UD1i13g8P6Kktdf .icon-shape .label{text-anchor:middle;}#mermaid-svg-8UD1i13g8P6Kktdf .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-8UD1i13g8P6Kktdf .rough-node .label,#mermaid-svg-8UD1i13g8P6Kktdf .node .label,#mermaid-svg-8UD1i13g8P6Kktdf .image-shape .label,#mermaid-svg-8UD1i13g8P6Kktdf .icon-shape .label{text-align:center;}#mermaid-svg-8UD1i13g8P6Kktdf .node.clickable{cursor:pointer;}#mermaid-svg-8UD1i13g8P6Kktdf .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-8UD1i13g8P6Kktdf .arrowheadPath{fill:#333333;}#mermaid-svg-8UD1i13g8P6Kktdf .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-8UD1i13g8P6Kktdf .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-8UD1i13g8P6Kktdf .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8UD1i13g8P6Kktdf .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-8UD1i13g8P6Kktdf .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8UD1i13g8P6Kktdf .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-8UD1i13g8P6Kktdf .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-8UD1i13g8P6Kktdf .cluster text{fill:#333;}#mermaid-svg-8UD1i13g8P6Kktdf .cluster span{color:#333;}#mermaid-svg-8UD1i13g8P6Kktdf div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-8UD1i13g8P6Kktdf .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-8UD1i13g8P6Kktdf rect.text{fill:none;stroke-width:0;}#mermaid-svg-8UD1i13g8P6Kktdf .icon-shape,#mermaid-svg-8UD1i13g8P6Kktdf .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-8UD1i13g8P6Kktdf .icon-shape p,#mermaid-svg-8UD1i13g8P6Kktdf .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-8UD1i13g8P6Kktdf .icon-shape .label rect,#mermaid-svg-8UD1i13g8P6Kktdf .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-8UD1i13g8P6Kktdf .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-8UD1i13g8P6Kktdf .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-8UD1i13g8P6Kktdf :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 2D 路径
空间路径
用户进入设置中心
选择导航方式
滚动列表
点击条目
页面跳转 push 详情页
返回 pop 回列表
视线/手势选中前台卡片
卡片沿 Z 轴前移并放大
二级详情在背景层淡入
手势后推 = 返回上一层
卡片回落到原始 z 位置
两条路径的差别不只是动效。2D 路径里,返回是"撤销一次操作";空间路径里,返回是"退后一步"。后者更接近人在物理空间里的直觉,代价是用户必须始终知道自己在哪一层------这正是设计门槛,系统替不了你。
再往前一步,如果这个设置中心接入 3DGS 场景(比如相机类 App 让用户在实景空间里调整参数),设置项就不再是列表,而是悬浮在环境里的锚点。Spatial Recon Kit 加载 3DGS 模型后,配合 ArkGraphics 3D 的相机和光源,锚点会随视角变化产生合理的透视关系。这类场景里,设置项的空间位置本身就是语义:贴近实物的参数(比如滤镜强度)就该靠近那件实物。
几条小经验
- 先问"Z 轴在这个页面承担什么职责",再动手写代码。如果答案只是"好看",大概率不该上 3D。
- 把深度当层级用,别当装饰用。让用户在纵深里找到方向,比让元素飘起来更有价值。
- 2D 到 3D 的第一步通常是"分层的 Stack",不是"加载一个 3D 模型"。绝大多数页面用层叠 + 透视就够了。
- 透视值(
perspective)要统一。同屏元素用不同视距,会让人眼判断不了它们的远近关系。 - 动效时间控制在 300~500ms。空间位移过大又太慢,比平面动画更容易引发不适。
注意一下下哦
- 把
zIndex当成 Z 轴 。zIndex只影响绘制顺序,不产生透视和遮挡的物理感。要真实纵深,得用translate的z配合perspective。 rotate和scale的锚点打架 。两个属性都设centerX/centerY时,以属性链里后设置的为准。写链式调用时别想当然。- 透视中心默认在组件自身。做整屏空间感时,往往需要把旋转锚点对齐到屏幕中心或观察者位置,否则所有元素各转各的。
- 忽略可触及范围。平面里屏幕边缘还能点,空间里"远处"的控件既难点中也不该高频使用。
- 只做视觉不做状态。元素进出视野、被遮挡、过远这些状态如果没设计,用户会以为自己操作失败了。
空间计算的门槛,一半在"怎么写",另一半在"为什么这么写"。系统把前一半按低了,后一半还得靠自己。
#鸿蒙 #HarmonyOS 7(API 26)