一块界面元素看起来"真不真",取决于两件事:它的表面是什么材质,以及光是怎么打到它上面的。材质给了物体性格,光照给了物体体积,两者叠加才有立体感。问题在于,真实感的东西都要算------模糊要算、反射要算、阴影要算,而空间计算的每一帧都紧张。设计的功夫在于知道哪些能省、哪些不能省。
材质在空间里的语义
在界面里选材质不是审美问题,是语义问题。每种材质都在告诉用户"这是什么、能不能点、在哪一层"。
| 材质 | 视觉特征 | 传递的语义 | 典型位置 |
|---|---|---|---|
| 毛玻璃 / 模糊 | 背景透出但被虚化 | "我在你上面,但没挡住你" | 浮层、菜单、控制面板 |
| 金属 | 高对比、锐利高光、冷 | 坚实、精密、硬件感 | 开关、旋钮、仪表 |
| 亚克力 / 半透明带色 | 半透、带一层统一色调 | 系统级容器、有边界但不沉重 | 卡片、导航栏 |
| 纯色 / 无材质 | 完全遮挡 | 内容本身,不是容器 | 正文背景 |
这里有一条规律:遮挡力越强,层级越靠上。毛玻璃能让底层内容透出来,所以它是"悬浮"的;纯色完全遮挡,所以它是"主体"。用户看透不透,就能判断前后关系,不需要额外标注。
HarmonyOS 7 的沉浸式系统材质把这套语义做成了五档 ImmersiveStyle(ULTRA_THIN 到 ULTRA_THICK),从很透到很模糊。选型时按层级来:浮动按钮和轻提示用薄,常规卡片用 REGULAR,菜单用 THICK,模态弹窗用 ULTRA_THICK。系统会按设备算力自动调整参数,应用不需要为不同设备各写一套。
光照模型怎么塑造真实感
真实场景里,光由几层叠加:环境光打底,方向光塑形,高光提示材质。做界面时可以把这三层拆开想。
环境光(ambient)。均匀地照亮所有面,没有方向。它决定整体亮度,太高会显得平、没有体积,太低会让暗部糊成一片。界面里近似对应的是背景色的明度。
方向光(directional) 。从一个方向平行照射,产生明暗面和投影。它是塑造"物体是立体的"最主要的因素。ArkGraphics 3D 里 LightType.DIRECTIONAL 就是这一类,用来模拟太阳光。
高光 / 镜面反射(specular)。材质对光的镜面响应,决定表面是粗糙(哑光)还是光滑(反光)。它是最"贵"的一层,也是最能让材质区分的细节,按需给。
在真正的 3D 场景里,这三层交给 ArkGraphics 3D 的光源系统:
typescript
import { Camera, Light, LightType, Scene, SceneResourceFactory } from '@kit.ArkGraphics3D';
@Entry
@Component
struct LightingScene {
@State sceneOpt: SceneOptions | undefined = undefined;
private light: Light | undefined = undefined;
private cam: Camera | undefined = undefined;
aboutToAppear() {
// 加载 glTF 模型,模型本身带网格与材质
Scene.load($rawfile('gltf/DamagedHelmet/glTF/DamagedHelmet.glb'))
.then(async (scene: Scene) => {
let rf: SceneResourceFactory = scene.getResourceFactory();
// 方向光:模拟主光,为模型塑形
this.light = await rf.createLight({ name: 'mainLight' }, LightType.DIRECTIONAL);
this.light.color = { r: 1.0, g: 0.96, b: 0.9, a: 1.0 }; // 略偏暖的白光
// 相机决定观察角度,也决定光照看起来的角度
this.cam = await rf.createCamera({ name: 'camera' });
this.cam.enabled = true;
this.cam.position.z = 5;
this.cam.fov = 60 * Math.PI / 180;
this.sceneOpt = { scene: scene, modelType: ModelType.SURFACE } as SceneOptions;
})
.catch((error: string) => {
console.error(`Scene load failed: ${error}`);
});
}
build() {
if (this.sceneOpt) {
Component3D(this.sceneOpt)
.width('100%')
.height('100%')
} else {
Text('加载中')
}
}
}
方向光的颜色会改变整个场景的色温:偏暖的白像黄昏,偏冷的白像阴天。改色一两步,情绪就换了一个基调。
在 2.5D 界面里模拟光照
大多数界面元素不是真 3D 模型,但可以用几种便宜的方式模拟光照。这是性价比最高的做法。
- 阴影代替投影 :用
shadow()的偏移和模糊半径模拟元素离背景的高度。偏移越大、模糊越散,元素看起来浮得越高。 - 顶部高光代替方向光 :用一层
linearGradient在元素顶部加一道浅色渐变,模拟从上方来的光。 - 模糊代替景深 :用
backgroundBlurStyle把底层内容虚化,制造前后景深差。
typescript
@Entry
@Component
struct MaterialCard {
build() {
Column() {
Text('空间化卡片')
.fontSize(18)
.fontColor($r('sys.color.font_primary'))
}
.width(300)
.height(160)
.padding(20)
.borderRadius(24)
// 背景模糊:让底下内容透出但被虚化,"我在上层"
.backgroundBlurStyle(BlurStyle.Regular, {
colorMode: ThemeColorMode.LIGHT,
adaptiveColor: AdaptiveColor.DEFAULT,
scale: 0.1
})
// 顶部高光:模拟从上方打来的光,给卡片一点"厚度"
.linearGradient({
angle: 135,
colors: [
['#33FFFFFF', 0.0], // 左上偏亮
['#00FFFFFF', 0.5] // 到中间完全透明
]
})
// 阴影:偏移越大浮得越高,这里表示它悬在背景之上
.shadow({
radius: 24,
color: '#40000000',
offsetX: 0,
offsetY: 12
})
}
}
真实感与性能的权衡
真实感每一项都要算,性能预算有限,就必须排序。下面这张表把常见手段按开销排开,方便取舍。
| 手段 | 真实感贡献 | 性能开销 | 建议 |
|---|---|---|---|
| 静态模糊(EffectKit.blur) | 中 | 低 | 内容不变的背景图 |
| 实时模糊(backgroundBlurStyle) | 高 | 高 | 只给少数浮层 |
| backdropBlur / blur 动态调参 | 高 | 很高 | 只在跟手交互时短时启用 |
| 阴影 shadow | 中 | 低 | 普遍可用 |
| 渐变高光 | 低 | 极低 | 所有卡片都能加 |
| ArkGraphics 3D 光照 | 很高 | 很高 | 只在主视觉场景用 |
几条经验性的边界:
- 实时模糊按帧渲染,是最贵的常规手段之一 。同一屏里叠三层实时模糊,中低端设备就掉帧。内容不变时改用静态模糊(
EffectKit.blur)能省下绝大部分开销。 - 沉浸式材质本身有算力分档 。
getGlobalMaterialLevel()返回SMOOTH的设备上,厚材质会退化成更便宜的实现,不要假设所有设备都有一致的高光与折射。 - 3D 场景控制面数和纹理尺寸。真实感来自光照和材质,不来自堆多边形;一个 5 万面的模型和一个 2 万面的模型,在手机屏幕上往往看不出差别,但帧率差很多。
- 按需降级,不要一刀切。先给关键路径上真实感,非关键路径用渐变和阴影凑。
案例:空间化控制台
一个空间化的智能家居控制台:中间是一张悬浮的主控卡片,四周环绕若干设备小卡,背景是一张房间实景图。目标是有立体感、能看出层级、还要在中端设备上稳在 60fps。
做法拆解:
- 背景实景图保持清晰,不加模糊,作为"地面"参照;
- 主控卡片用
REGULAR实时模糊 + 阴影,明确它是悬浮层;顶部加一道渐变高光; - 设备小卡用纯色半透明背景 + 阴影,不加实时模糊------数量多,省下来的预算给主卡;
- 选中态用一圈边缘高亮的渐变边框,而不是改整个卡片的模糊度(改模糊度会触发整卡重绘);
- 主视觉里有一个可旋转的 3D 设备模型,用 ArkGraphics 3D 单独渲染在一个
Component3D里,方向光固定,不跟着界面滚动重算。
typescript
@Entry
@Component
struct DeviceCard {
@Prop selected: boolean;
build() {
Column() {
Text('设备')
.fontSize(14)
.fontColor($r('sys.color.font_primary'))
}
.width(96)
.height(96)
.borderRadius(20)
// 小卡用半透明纯色,避免实时模糊的数量开销
.backgroundColor('#1AFFFFFF')
.shadow({ radius: 12, color: '#33000000', offsetX: 0, offsetY: 6 })
// 选中态:加一圈高亮描边,不改变模糊度,避免重绘整卡
.border(this.selected
? { width: 2, color: '#FFD28A' }
: { width: 0, color: Color.Transparent })
.animation({ duration: 200, curve: Curve.EaseOut })
}
}
#mermaid-svg-YTih8u7EzIoaZh5F{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-YTih8u7EzIoaZh5F .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-YTih8u7EzIoaZh5F .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-YTih8u7EzIoaZh5F .error-icon{fill:#552222;}#mermaid-svg-YTih8u7EzIoaZh5F .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-YTih8u7EzIoaZh5F .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-YTih8u7EzIoaZh5F .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-YTih8u7EzIoaZh5F .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-YTih8u7EzIoaZh5F .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-YTih8u7EzIoaZh5F .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-YTih8u7EzIoaZh5F .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-YTih8u7EzIoaZh5F .marker{fill:#333333;stroke:#333333;}#mermaid-svg-YTih8u7EzIoaZh5F .marker.cross{stroke:#333333;}#mermaid-svg-YTih8u7EzIoaZh5F svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-YTih8u7EzIoaZh5F p{margin:0;}#mermaid-svg-YTih8u7EzIoaZh5F .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-YTih8u7EzIoaZh5F .cluster-label text{fill:#333;}#mermaid-svg-YTih8u7EzIoaZh5F .cluster-label span{color:#333;}#mermaid-svg-YTih8u7EzIoaZh5F .cluster-label span p{background-color:transparent;}#mermaid-svg-YTih8u7EzIoaZh5F .label text,#mermaid-svg-YTih8u7EzIoaZh5F span{fill:#333;color:#333;}#mermaid-svg-YTih8u7EzIoaZh5F .node rect,#mermaid-svg-YTih8u7EzIoaZh5F .node circle,#mermaid-svg-YTih8u7EzIoaZh5F .node ellipse,#mermaid-svg-YTih8u7EzIoaZh5F .node polygon,#mermaid-svg-YTih8u7EzIoaZh5F .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-YTih8u7EzIoaZh5F .rough-node .label text,#mermaid-svg-YTih8u7EzIoaZh5F .node .label text,#mermaid-svg-YTih8u7EzIoaZh5F .image-shape .label,#mermaid-svg-YTih8u7EzIoaZh5F .icon-shape .label{text-anchor:middle;}#mermaid-svg-YTih8u7EzIoaZh5F .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-YTih8u7EzIoaZh5F .rough-node .label,#mermaid-svg-YTih8u7EzIoaZh5F .node .label,#mermaid-svg-YTih8u7EzIoaZh5F .image-shape .label,#mermaid-svg-YTih8u7EzIoaZh5F .icon-shape .label{text-align:center;}#mermaid-svg-YTih8u7EzIoaZh5F .node.clickable{cursor:pointer;}#mermaid-svg-YTih8u7EzIoaZh5F .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-YTih8u7EzIoaZh5F .arrowheadPath{fill:#333333;}#mermaid-svg-YTih8u7EzIoaZh5F .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-YTih8u7EzIoaZh5F .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-YTih8u7EzIoaZh5F .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YTih8u7EzIoaZh5F .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-YTih8u7EzIoaZh5F .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YTih8u7EzIoaZh5F .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-YTih8u7EzIoaZh5F .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-YTih8u7EzIoaZh5F .cluster text{fill:#333;}#mermaid-svg-YTih8u7EzIoaZh5F .cluster span{color:#333;}#mermaid-svg-YTih8u7EzIoaZh5F 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-YTih8u7EzIoaZh5F .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-YTih8u7EzIoaZh5F rect.text{fill:none;stroke-width:0;}#mermaid-svg-YTih8u7EzIoaZh5F .icon-shape,#mermaid-svg-YTih8u7EzIoaZh5F .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-YTih8u7EzIoaZh5F .icon-shape p,#mermaid-svg-YTih8u7EzIoaZh5F .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-YTih8u7EzIoaZh5F .icon-shape .label rect,#mermaid-svg-YTih8u7EzIoaZh5F .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-YTih8u7EzIoaZh5F .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-YTih8u7EzIoaZh5F .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-YTih8u7EzIoaZh5F :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
否
确定元素的层级
是悬浮层吗
实时模糊 backgroundBlurStyle
半透明纯色 + 阴影
数量是否超过 2 到 3 个
只保留关键元素的实时模糊
保留
叠加渐变高光
按材质等级降级样式
几条经验哦
- 先定层级、再选材质。层级对了,即使只用纯色 + 阴影,用户也能看懂前后关系。
- 阴影偏移比模糊半径更能表达"高度"。浮得高的元素,偏移大、模糊散;贴着背景的元素,偏移小、阴影实。
- 实时模糊按数量收费。同屏超过两三个,就要开始考虑用静态模糊或半透明纯色替代。
- 用系统材质时别自己再叠一层阴影或背景色,会和材质效果打架。
容易出问题的地方哈
- 把
backgroundBlurStyle当成万能背景。它只模糊背景内容,如果组件自身没有背景内容可模糊,效果就是没有。 - 中低端设备上材质模糊带
scale调得过大,模糊采样范围暴涨,直接掉帧。scale保持小值起步。 - 3D 模型加载是异步的,
Component3D要在sceneOpt就绪后再渲染,否则出现空白或闪烁。加载期间给一个占位提示。 - 给大量列表项逐个加实时模糊,滚动时卡顿明显。列表项用静态效果,模糊留给少数固定浮层。