空间界面性能预算:同时渲染多少 3D 节点不卡

2D 界面做性能预算有个简单经验——同屏 DOM 节点别超 500,list 虚拟滚动,基本不卡。到了空间界面这套经验失效,因为 3D 节点的开销构成完全不同:draw call、三角形数、纹理带宽、着色器复杂度,每个维度都能单独卡。我们做商品空间展厅第一版放了 200 个商品 3D 模型,真机帧率掉到 22fps,砍到 50 个反而稳 60fps,但 50 个复杂模型又比 200 个简单立方体卡。光看节点数量定不了预算。这篇把 3D 节点开销拆开量化,给一份能按设备分档的性能预算表。

能力面:3D 节点渲染开销构成

一个 3D 节点渲染到屏幕,开销来自四个维度:

Draw call

每个 draw call 是 CPU 给 GPU 发一次绘制指令。指令本身开销不大(几十微秒),但 CPU→GPU 的状态切换开销大——切 shader、绑纹理、传 uniform,每次切换几百微秒。100 个 draw call 如果每个都切 shader,光状态切换就 50ms,帧率直接掉。

空间界面比 2D 容易 draw call 爆炸,因为 3D 节点各自材质不同,难以合批。200 个商品模型每个用不同纹理,就是 200 个 draw call。2D 界面 200 个 Image 经常能合批成 1~2 个 draw call(共享图集),3D 没这个待遇。

三角形数

每个三角形要跑一次顶点着色器 + 片元着色器。三角形总数 = 节点数 × 单节点三角形数。50 个 10000 三角形的模型 = 50 万三角形,200 个 100 三角形的立方体 = 2 万三角形,前者三角形总数反而多 25 倍。

GPU 每帧能跑的三角形数有上限,麒麟 9020 实测约 300 万三角形/帧(60fps),9030 约 500 万。超出就掉帧。

纹理带宽

纹理从内存传到 GPU 显存,带宽是瓶颈。一个 2048×2048 RGBA 纹理 16MB,传一次 16MB,60fps 一秒 60 次 = 960MB/s。GPU 显存带宽有限,超了就卡。

空间界面常用大纹理(3DGS 点云、高分辨率模型贴图),纹理带宽比 2D 严重得多。2D 界面图集合批后纹理常驻显存,3D 模型各自纹理频繁切换,带宽吃紧。

着色器复杂度

着色器是 GPU 上跑的程序,复杂度决定每个像素的耗时。简单 unlit shader 一个像素几纳秒,PBR shader(带光照、阴影、反射)一个像素几十纳秒,差 10 倍。

空间界面为了真实感常用 PBR,但 PBR 在移动 GPU 上开销大。我们测过同一个模型,unlit shader 60fps,换 PBR 掉到 38fps。

约束面:设备差异与帧率目标

设备 GPU 性能差异

我们测了三台设备,单帧 16.67ms(60fps)预算下能渲染的三角形数和 draw call 数:

设备芯片三角形上限draw call 上限纹理带宽
Vision Pro麒麟 9030500 万28012GB/s
Vision麒麟 9020300 万1808GB/s
折叠屏(空间模式)麒麟 9010120 万804GB/s

差 4 倍。性能预算不能一刀切,必须按设备分档。我们分了三档:高端(9030+)200 节点预算,中端(9020)120 节点预算,低端(9010 及以下)60 节点预算。

帧率目标:90fps vs 60fps

空间界面帧率目标比 2D 严——头显设备 90fps 起步,低于 90fps 用户会有晕动感。手持设备(折叠屏空间模式)60fps 可接受。

90fps 单帧预算 11.1ms,比 60fps 的 16.67ms 少 33%。同样 GPU 性能下,90fps 能渲染的三角形数只有 60fps 的 60%。90fps 不是 60fps 的 1.5 倍性能要求,是 1.67 倍(因为剩余时间少)。

我们定帧率目标策略:高端设备 90fps,中端 60fps,低端 30fps(半帧渲染)。低端 30fps 会有轻微晕动感,但比卡到 15fps 强。

场景落地:10 到 200 个 3D 节点的帧率曲线

我们在 Vision(9020)上测了从 10 到 200 个 3D 节点的帧率曲线。两组测试:A 组用简单立方体(12 三角形,无纹理,unlit shader),B 组用商品模型(约 8000 三角形,2048×2048 纹理,PBR shader)。

节点数A 组帧率A 组 draw callB 组帧率B 组 draw callB 组 GPU 耗时
10601060104.2ms
20602060206.8ms
506050585014.1ms
806080428022.3ms
120601202812035.6ms
150581501815048.2ms
200452001220062.7ms

A 组 200 个简单立方体才掉到 45fps,B 组 80 个就掉到 42fps。节点数量不是关键,单节点开销 × 节点数才是。

B 组掉帧的瓶颈分析

B 组 80 节点掉到 42fps,瓶颈是哪个?拆解单帧 23.8ms(42fps)的耗时:

阶段耗时占比
CPU draw call 准备4.1ms17%
GPU 顶点着色器3.2ms13%
GPU 片元着色器(PBR)11.8ms50%
纹理带宽3.5ms15%
其他1.2ms5%

瓶颈是片元着色器(PBR),占一半。把 PBR 换成 unlit shader 重测,80 节点帧率从 42 升到 58。真实感是有代价的,性能预算要按 shader 复杂度分档。

三台设备的对比实测

同样的 B 组测试(中等节点 PBR)在三台设备上跑,节点数到帧率的曲线差异能看出设备档位的价值:

节点数9030 帧率9020 帧率9010 帧率
20906060
50906038
80884222
120722812
20048126

9030 在 80 节点仍能 88fps,9020 掉到 42fps,9010 掉到 22fps。同节点数下设备差异最大 4 倍帧率。这验证了预算必须分档——9030 放 80 节点稳 90fps,9010 放 80 节点完全不可用。

9010 在 20 节点以上就掉到 60fps 以下,意味着低端设备空间界面基本只能放简单节点,中等 PBR 节点超 20 个就卡。低端设备的预算表里中等节点只给 20 个,是有数据支撑的。

节点数只是入口,真正定预算要走完下面这条分配与验证链。

超预算

未超

否

是

统计场景 3D 节点数

估算 draw call 与三角形数

对照设备档位预算表

是否超预算?

合并批次 / 降 LOD / 减节点

帧率验证

帧率达标?

预算定稿

超预算先做合批和 LOD,降到预算内再验证帧率,帧率不稳就回到降级环节继续压。

真机数据:性能预算分档表

综合设备差异、帧率目标、节点复杂度,我们定了三档预算:

设备档帧率目标简单节点(unlit)中等节点(8000 三角形 + PBR)复杂节点(3DGS 点云)
高端(9030+)90fps200808
中端(9020)60fps200505
低端(9010)30fps80202

复杂节点指 3DGS 点云,单节点开销是普通模型的 10 倍以上,预算很小。3DGS 的 fillrate 敏感特性决定了同屏点数是瓶颈,不是总点数(参考系列硬事实 5)。

预算超了怎么降级

实际场景节点数常超预算。商品展厅想放 200 个商品,中端预算只 50 个。降级策略:

  1. 视锥裁剪:只渲染视锥内的节点,视锥外的不渲染。200 个商品视锥内通常 30~50 个,刚好卡预算。
  2. LOD 切换:远距离节点用低 LOD(少三角形、小纹理)。视距 3m 外的模型用 1000 三角形 LOD 替代 8000 三角形。
  3. shader 降级:远距离节点 PBR 换 unlit。视距 5m 外基本看不出 PBR 效果,换 unlit 视觉无损。
  4. 冻结远距离动画:远距离节点不跑骨骼动画,省 CPU。

这套组合下来,200 个商品展厅在中端设备能稳 60fps,视锥内 30~50 个用 PBR,视锥外用 LOD + unlit 不渲染。

不同场景的预算实例

同样是空间界面,不同场景预算分配差很多。我们测了三个典型场景的预算拆解(中端 9020,60fps):

场景总预算简单节点中等节点复杂节点备注
商品展厅50 中等0500全 PBR 模型,视锥裁剪
文旅展馆80 中等 + 2 复杂080280 个展品 + 2 个 3DGS 场景
商品详情页1 复杂 + 10 中等01011 个 3DGS 商品 + 10 个 UI 装饰
空间桌面30 简单300030 个 unlit 卡片,性能富余

商品详情页只 11 个节点但包含 1 个 3DGS,3DGS 单节点开销是普通模型的 10 倍以上,预算反而紧。空间桌面 30 个 unlit 卡片开销小,性能富余可以用来跑动效。预算分配要按场景定,不要全用一套默认。

我们一开始所有场景都套商品展厅的 50 中等节点预算,结果空间桌面被限到 30 个卡片还有富余,UI 设计师想加动效被告知"预算满了"——其实预算没满,是套错了档。后来按场景拆预算表,每个场景独立配置。

踩坑与取舍

坑一:200 个简单立方体卡但 50 个复杂模型不卡

这个反直觉现象我们一开始没想通。200 个立方体三角形总数 2400,50 个复杂模型三角形总数 40 万,复杂模型三角形多 166 倍,反而帧率高。

debug 后发现瓶颈不是三角形数,是 draw call 状态切换。200 个立方体我们每个给了不同颜色(演示用),每次 draw call 都切 uniform,状态切换开销 200 × 0.3ms = 60ms。50 个复杂模型共享 3 种 shader,状态切换 50 × 0.1ms = 5ms。draw call 状态切换才是瓶颈,不是三角形数。

修复是把 200 个立方体颜色合批——用顶点颜色 attribute 而非 uniform 传色,一次 draw call 画 200 个。合批后帧率从 45 升到 60。这个优化 2D 界面也常用,但 3D 容易忽略,因为 3D 模型各自材质不同,合批条件苛刻。

坑二:纹理驻留显存策略失效

我们以为模型纹理常驻显存,第一次加载后不再传带宽。实际 GPU 显存有限(9020 约 2GB),50 个 2048×2048 纹理就 800MB,超了会自动淘汰。淘汰后再渲染要重新传,带宽爆炸。

这个坑在视锥裁剪 + 远距离 LOD 切换时特别严重——用户转头,视锥内节点变,新节点的纹理可能被淘汰了,重新加载时帧率瞬间掉。我们加了纹理 LRU 缓存,保留最近 16ms 内用过的纹理,淘汰时先淘汰最久未用的。但 LRU 也不能完全避免,显存不够就是不够。

最终解法是纹理压缩——用 ASTC 6×6 压缩,2048×2048 纹理从 16MB 压到 1.5MB。50 个纹理 75MB,显存够用。压缩有视觉损失但商品展示场景可接受。

坑三:90fps 目标在中端设备达不到

我们一开始定全设备 90fps 目标,中端设备(9020)跑商品展厅死活上不去,最好 72fps。原因是 9020 GPU 性能不够 90fps 渲染 50 个 PBR 节点。

争论了一周,最终妥协——高端 90fps,中端 60fps。中端 60fps 在手持设备(非头显)可接受,头显中端设备暂时不上商品展厅这个重场景。这个妥协意味着中端用户拿不到 90fps 体验,但比强行 90fps 卡到 45fps 强。

坑四:纹理流式加载导致帧率尖刺

商品展厅 200 个商品的纹理总量约 300MB,显存只有 2GB,不可能全加载。我们用按需加载——视锥内的商品纹理加载,视锥外的释放。但用户快速转头时,视锥内突然出现 10 个新商品,10 个纹理同时加载,每个加载 30ms,总 300ms 的加载尖刺,帧率瞬间掉到 5fps。

这个尖刺不是渲染开销,是 I/O 开销——纹理从闪存读到内存再传到显存。修复是预加载视锥边缘外 30° 的纹理,用户转头前纹理已经在显存里。预加载用低优先级异步,不抢当前帧的 I/O 带宽。

预加载的代价是显存占用增加——边缘外 30° 大约多 30 个商品的纹理,多占 45MB 显存。但比转头时卡 300ms 强。预加载范围要调——太小卡,太大占显存。30° 是我们测的平衡点,20° 仍偶发卡顿,40° 显存吃紧。

坑五:draw call 合批破坏材质多样性

坑一里我们把 200 个立方体合批成 1 个 draw call,帧率从 45 升到 60。但合批要求所有立方体共享同一个 shader,不能各自有不同材质。商品展厅里 200 个商品各自材质不同(金属、塑料、布料),合批条件不满足。

我们试了按材质分组合批——同材质的商品合一批,不同材质各一批。200 个商品按 8 种材质分 8 批,draw call 从 200 降到 8。但分批要求同材质商品连续渲染,不能穿插,这要重排渲染顺序。重排后 z-buffer 排序乱了,半透明商品渲染出错。

最终妥协——不透明商品按材质分组合批,半透明商品不合批单独渲染。不透明商品占 90%,合批后 draw call 从 200 降到 20(8 批不透明 + 半透明各自单独)。帧率从 38 升到 55,没完全到 60 但可接受。半透明商品的合批要等官方支持半透明排序合批的 API。

取舍:预算留多少余量

预算不能贴着上限用,要留余量。我们留 20% 余量——预算 50 节点实际放 40 个,剩下 10 个余量给临时加载、动画峰值、用户操作触发的额外渲染。

余量太小会偶发掉帧——用户操作触发一个粒子特效,多 5 个 draw call,预算贴满就掉帧。余量太大浪费性能——预算 40 实际能放 50,画面空。20% 是我们测出来的平衡点,10% 偶发掉帧,30% 画面明显空。

取舍:90fps 目标在头显的硬约束

头显设备 90fps 不是体验要求,是生理要求——低于 90fps 用户会有晕动症,不是"体验差"是"生理不适"。这意味着头显场景的预算必须按 90fps 算,不能按 60fps 算。

我们头显商品展厅在中端 9020 上跑 90fps,预算从 50 节点砍到 30 节点。30 个商品展厅视觉上偏空,设计师不满意。争论后妥协——头显中端设备展厅场景只放 30 个,但每个商品用更高精度模型(视觉补偿数量少),高端设备(9030)放 50 个标准精度。这个妥协意味着中端头显用户看到的是"少而精",高端是"多而全",体验有差异但都不晕。

可运行代码:性能监控与预算检查

// SpatialRenderBudget.ets —— 3D 节点渲染性能预算与监控

// 设备档位
enum DeviceTier {
  High,    // 9030+
  Medium,  // 9020
  Low      // 9010 及以下
}

// 节点复杂度档
enum NodeComplexity {
  Simple,    // unlit,< 1000 三角形
  Medium,    // PBR,~8000 三角形
  Complex    // 3DGS 点云
}

// 预算表:[设备档][复杂度] = 节点数上限
const BUDGET: Record<DeviceTier, Record<NodeComplexity, number>> = {
  [DeviceTier.High]:   { [NodeComplexity.Simple]: 200, [NodeComplexity.Medium]: 80,  [NodeComplexity.Complex]: 8 },
  [DeviceTier.Medium]: { [NodeComplexity.Simple]: 200, [NodeComplexity.Medium]: 50,  [NodeComplexity.Complex]: 5 },
  [DeviceTier.Low]:    { [NodeComplexity.Simple]: 80,  [NodeComplexity.Medium]: 20,  [NodeComplexity.Complex]: 2 }
}

// 余量系数
const BUDGET_MARGIN = 0.8  // 实际使用 80% 预算

class RenderBudgetManager {
  private tier: DeviceTier
  // 当前各复杂度节点数
  private counts: Record<NodeComplexity, number> = {
    [NodeComplexity.Simple]: 0,
    [NodeComplexity.Medium]: 0,
    [NodeComplexity.Complex]: 0
  }
  // 性能监控
  private frameStats: Array<{ frameTime: number; drawCalls: number; gpuTime: number }> = []

  constructor(tier: DeviceTier) {
    this.tier = tier
  }

  // 检查能否再加一个节点
  canAddNode(complexity: NodeComplexity): boolean {
    const budget = BUDGET[this.tier][complexity] * BUDGET_MARGIN
    return this.counts[complexity] < budget
  }

  // 加节点
  addNode(complexity: NodeComplexity): boolean {
    if (!this.canAddNode(complexity)) {
      console.warn(`预算超限:${complexity} 节点已 ${this.counts[complexity]},预算 ${BUDGET[this.tier][complexity] * BUDGET_MARGIN}`)
      return false
    }
    this.counts[complexity]++
    return true
  }

  removeNode(complexity: NodeComplexity): void {
    if (this.counts[complexity] > 0) this.counts[complexity]--
  }

  // 当前预算使用率
  getUsage(): Record<NodeComplexity, number> {
    const usage: Partial<Record<NodeComplexity, number>> = {}
    for (const c of [NodeComplexity.Simple, NodeComplexity.Medium, NodeComplexity.Complex]) {
      usage[c] = this.counts[c] / (BUDGET[this.tier][c] * BUDGET_MARGIN)
    }
    return usage as Record<NodeComplexity, number>
  }

  // 每帧调用,记录性能数据
  onFrameEnd(frameTime: number, drawCalls: number, gpuTime: number): void {
    this.frameStats.push({ frameTime, drawCalls, gpuTime })
    if (this.frameStats.length > 60) this.frameStats.shift()  // 保留最近 60 帧
    // 检测掉帧
    if (frameTime > 18) {  // 60fps 阈值 16.67ms + 余量
      console.warn(`掉帧:frameTime=${frameTime.toFixed(1)}ms drawCalls=${drawCalls} gpuTime=${gpuTime.toFixed(1)}ms`)
      this.dumpUsage()
    }
  }

  // dump 当前预算使用
  private dumpUsage(): void {
    const usage = this.getUsage()
    console.info(`预算使用:Simple ${usage[NodeComplexity.Simple].toFixed(2)} | Medium ${usage[NodeComplexity.Medium].toFixed(2)} | Complex ${usage[NodeComplexity.Complex].toFixed(2)}`)
  }

  // 获取最近 60 帧平均帧率
  getAverageFps(): number {
    if (this.frameStats.length === 0) return 0
    const avgFrameTime = this.frameStats.reduce((s, f) => s + f.frameTime, 0) / this.frameStats.length
    return 1000 / avgFrameTime
  }
}

// 用法
const budget = new RenderBudgetManager(DeviceTier.Medium)
// 加节点前先检查预算
for (const item of items) {
  const complexity = item.isPointCloud ? NodeComplexity.Complex : NodeComplexity.Medium
  if (budget.addNode(complexity)) {
    renderNode(item)
  } else {
    // 预算超限,降级:用 LOD 或不渲染
    renderLodOrSkip(item)
  }
}

注意哦

  • 预算按设备分三档,高端 200/80/8,中端 200/50/5,低端 80/20/2(简单/中等/复杂)。
  • 预算留 20% 余量,贴满会偶发掉帧,余量给临时加载和动画峰值。
  • draw call 状态切换比三角形数更易成瓶颈,能合批尽量合批(顶点颜色 attribute 替代 uniform)。
  • 纹理用 ASTC 压缩,2048×2048 从 16MB 压到 1.5MB,避免显存淘汰导致重新加载卡顿。
  • PBR shader 开销是 unlit 的 10 倍,远距离节点(5m 外)降级 unlit,视觉无损。
  • 视锥裁剪 + LOD + shader 降级组合降级,单靠裁剪不够,远距离节点仍占预算。
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐