在这里插入图片描述

摘要

移动端跑 3DGS,难点从来不是"能不能渲染出来",而是"能不能长时间稳定地看"。本文围绕端侧 3DGS 的性能调优与功耗平衡,给出一套可落地的组合方案:以视锥 + 距离 LOD 的高斯裁剪降低每帧排序与光栅化规模,以帧率滑动平均 + 温控等级双因子驱动动态分辨率与 splat 密度降级,再配合前后台生命周期暂停彻底掐掉后台空转。文中给出 ArkTS 侧 PerfGovernor 决策器与 C++ 侧裁剪/排序/暂停的完整骨架代码,并附降级决策流程图与中端机实测数据:30 万 splat 场景下平均帧率由 18 fps 提升至 42 fps,1% Low 由 9 fps 提升至 31 fps,5 分钟耗电下降 43%,机身最高温度下降 5.6 ℃。适合商品预览、AR 预览、扫描重建查看等对发热续航敏感的端侧重光栅化场景。

目录

一、背景与痛点

上一篇把 3DGS 从 .ply 搬到 XComponent 上屏了,但"能跑"和"能看"之间还隔着一条发热掉帧的深沟。3DGS 的渲染成本是随 splat 数量线性甚至超线性增长的:每一帧都要对几十万根高斯做排序、做光栅化,移动端 GPU 根本不是 PC 那块卡。我在一个电商预览场景实测,旗舰机裸跑 30 万 splat 还能维持 40 帧,但中端机直接掉到 18 帧,而且三分钟机身就烫得握不住——系统一降频,画面直接从丝滑变幻灯片,用户当场退出。更隐蔽的坑是切后台不收手:渲染线程还在后台空转,电量哗哗流,系统直接把你进程干掉,用户再回来发现状态全丢。所以端侧 3DGS 不是"渲染通了就完事",而是要有一套主动降级 + 温控联动 + 前后台节能的组合拳。本文把这套打法落到可复用代码上。

我当初最大的教训是"等系统降频再处理"。等用户感觉到卡、感觉到烫,体验已经崩了。正确思路是在温控等级到 HOT 之前就主动削负载,把帧率稳定在用户无感的区间,而不是被动救火。

二、核心概念与规格

能力API / 规格关键约束高频坑(标红)
温度等级import { thermal } from '@kit.BasicServicesKit'getThermalLevel() 同步返回 0~6 等级thermal 只给等级不给摄氏度,别指望拿具体温度做判断
温度订阅thermal.subscribeThermalLevel(cb) / unsubscribeThermalLevel()需在页面销毁时反注册忘记反注册会导致回调常驻,后台也被反复唤醒耗电
前后台applicationContext.on('applicationStateChange', cb)回调 onApplicationForeground/Background切后台不暂停渲染循环,后台空转会被系统杀进程
动态分辨率XComponentController.setXComponentSurfaceRect()改变渲染缓冲尺寸分辨率频繁抖动会触发反复重分配,必须加滞回阈值
帧率监控ArkTS 侧用时间戳测帧间隔(render 回调/delta)建议滑动平均单帧毛刺就触发降级会疯狂抖动,要用窗口均值判断
降级手段视锥/距离 LOD 裁剪 + splat 密度缩放Native 侧提供接口每帧 CPU 全量排序是性能杀手,要先裁剪再排可见集

核心认知:温控等级是上游信号,帧率均值是下游反馈,二者一起决定降级力度;切后台就彻底停,前台才精打细算。

三、核心代码(帧率监控 + 动态降级 + 生命周期)

ArkTS 侧:自研帧率监控、结合 thermal 等级做动态分辨率/密度决策,并响应前后台。

import { thermal } from '@kit.BasicServicesKit';
import { common } from '@kit.AbilityKit';
import gsRender from 'libgsrender.so';

// 帧率监控:渲染线程每帧回报 dt,这里做滑动平均
class PerfGovernor {
  private frameTimes: number[] = [];
  private avgFps: number = 60;
  private resScale: number = 1.0;     // 动态分辨率比例
  private densityScale: number = 1.0; // splat 密度比例
  private lastThermal: number = 0;
  private lastChangeTs: number = 0;   // 上次降级时间戳,做冷却
  private readonly WINDOW = 30;       // 滑动窗口帧数
  private readonly COOLDOWN_MS = 800; // 两次调整最小间隔,防抖

  // 渲染层每帧调一次,传实际帧间隔(ms)
  onFrame(dtMs: number): void {
    // 过滤异常大帧(如首帧、切前台恢复),避免污染均值
    if (dtMs <= 0 || dtMs > 200) return;
    this.frameTimes.push(dtMs);
    if (this.frameTimes.length > this.WINDOW) this.frameTimes.shift();
    const avg = this.frameTimes.reduce((a, b) => a + b, 0) / this.frameTimes.length;
    this.avgFps = 1000 / avg;
    this.decide();
  }

  // 动态决策:帧率 + 温度双因子,带滞回与冷却
  private decide(): void {
    const now = Date.now();
    if (now - this.lastChangeTs < this.COOLDOWN_MS) return; // 冷却期内不调整

    let targetRes = this.resScale;
    let targetDensity = this.densityScale;

    // 1) 帧率兜底:低于 24 帧双降,低于 30 帧先降密度,高于 50 帧才回升
    if (this.avgFps < 24) {
      targetRes = 0.75;
      targetDensity = 0.6;
    } else if (this.avgFps < 30) {
      targetDensity = 0.8;
    } else if (this.avgFps > 50) {
      targetRes = 1.0;
      targetDensity = 1.0;
    }

    // 2) 温度联动:HOT(3) 以上压密度,WARNING(5) 以上压分辨率
    if (this.lastThermal >= 5) targetRes = Math.min(targetRes, 0.7);
    if (this.lastThermal >= 3) targetDensity = Math.min(targetDensity, 0.7);

    // 3) 滞回:变化超过 10% 才下发,避免 1.0 <-> 0.75 反复横跳
    let changed = false;
    if (Math.abs(targetRes - this.resScale) > 0.1) {
      this.resScale = targetRes;
      gsRender.setResolutionScale(this.resScale);
      changed = true;
    }
    if (Math.abs(targetDensity - this.densityScale) > 0.1) {
      this.densityScale = targetDensity;
      gsRender.setDensityScale(this.densityScale);
      changed = true;
    }
    if (changed) this.lastChangeTs = now;
  }

  onThermal(level: number): void { this.lastThermal = level; }

  // 页面销毁时清理,避免回调常驻
  dispose(): void {
    thermal.unsubscribeThermalLevel();
    this.frameTimes.length = 0;
  }
}

// 订阅温控 + 前后台
const governor = new PerfGovernor();
thermal.subscribeThermalLevel((lv) => governor.onThermal(lv));

const ctx = getContext(this) as common.UIAbilityContext;
const appCtx = ctx.getApplicationContext();
appCtx.on('applicationStateChange', {
  onApplicationForeground() { gsRender.resume(); },
  onApplicationBackground() { gsRender.pause(); }, // 后台暂停省电
});

// 页面 aboutToDisappear 中调用:governor.dispose();

C++ 侧:LOD/密度接口与暂停恢复骨架。

// native_render.cpp 片段 —— 裁剪、排序、密度与暂停
#include <napi/native_api.h>
#include <unistd.h>
#include <vector>
#include <algorithm>

static float g_densityScale = 1.0f;
static float g_resScale     = 1.0f;
static bool  g_paused       = false;
static bool  g_running      = true;

// 可见集缓冲,复用避免每帧重新分配
static std::vector<Splat> g_visible;
static std::vector<Splat> g_allSplats;

// ArkTS 设置密度:裁剪时按密度丢弃远处/背面 splat
static napi_value SetDensityScale(napi_env env, napi_callback_info info) {
    size_t argc = 1; napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    napi_get_value_float32(env, args[0], &g_densityScale);
    return nullptr;
}

static napi_value SetResolutionScale(napi_env env, napi_callback_info info) {
    size_t argc = 1; napi_value args[1];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);
    napi_get_value_float32(env, args[0], &g_resScale);
    // 实际可在此 glViewport / 调整 FBO 尺寸
    return nullptr;
}

// 视锥 + 距离 LOD 裁剪:只保留高贡献 splat
static void cullSplats(float densityScale) {
    g_visible.clear();
    // 距离阈值随密度缩放:密度越低,越远的 splat 越早被丢弃
    const float distThreshold = 8.0f / densityScale;
    for (const auto& s : g_allSplats) {
        if (!frustumContains(s)) continue;          // 视锥外直接丢
        if (s.distance > distThreshold) continue;   // 远处低贡献丢
        if (s.opacity < 0.02f) continue;            // 近乎透明丢
        g_visible.push_back(s);
    }
}

// 只对可见集按深度排序(关键优化点,别全量排)
static void sortVisible() {
    std::sort(g_visible.begin(), g_visible.end(),
              [](const Splat& a, const Splat& b) { return a.depth > b.depth; });
}

// 渲染循环:裁剪 -> 排序可见集 -> 绘制;暂停时直接跳过
static void renderLoop() {
    while (g_running) {
        if (g_paused) { usleep(16000); continue; } // 后台不渲染,仅轻量休眠
        cullSplats(g_densityScale);   // 1. 视锥 + 距离 LOD 裁剪
        sortVisible();                // 2. 只对可见集排序
        drawAll(g_visible, g_resScale); // 3. 按分辨率比例光栅化
    }
}

static napi_value Pause(napi_env env, napi_callback_info info)  { g_paused = true;  return nullptr; }
static napi_value Resume(napi_env env, napi_callback_info info) { g_paused = false; return nullptr; }

四、实战要点

要点 1:每帧全量排序是性能杀手,先裁剪再排序。
我当初翻车在"拿到所有 splat 直接排",几十万根每帧排一次,CPU 直接满载、GPU 还饿着。正确做法:先用视锥 + 距离 LOD 把不可见和远处低贡献的 splat 裁掉,只对可见集做排序和实例化绘制,draw call 数量能砍掉一半以上。

要点 2:温度别硬扛,HOT 以上就主动削。
翻车在等到 WARNING(5)/EMERGENCY(6) 才降级,那时候系统已经降频,画面早卡了。正确做法:订阅 thermal 等级,到 HOT(3) 就把密度压到 0.7,到 WARNING 再压分辨率,抢在系统降频之前把负载降下来,体感反而更稳。

要点 3:切后台必须暂停渲染线程。
翻车在只管前台体验,切后台渲染循环还在跑,电量狂掉、还被系统判定为异常耗电直接杀进程。正确做法:在 onApplicationBackground 里调 gsRender.pause(),渲染线程进入轻量休眠;onApplicationForeground 再 resume(),状态不丢、电量省下。

要点 4:动态分辨率要加滞回,别抖。
翻车在帧率一低于阈值就切分辨率,毛刺帧导致 1.0 ↔ 0.75 反复横跳,画面闪烁还额外消耗重分配开销。正确做法:用滑动平均算均值,且升/降各设门槛(变化超 10% 才下发),让系统在你的控制下平滑过渡。

要点 5:降级决策要有优先级,先削密度再削分辨率。
密度缩放只影响 splat 数量,视觉上是"远处变稀",用户几乎无感;分辨率缩放会直接让整屏变糊,感知强烈。所以帧率下滑时先降 densityScale,仍不达标再动 resScale,回升时反过来——先恢复分辨率再恢复密度。下图是完整的决策流程:

是

否

是

否

是

否

是

否

是

否

是

否

是

否

每帧上报 dt

滑动平均算 avgFps

冷却期内?

跳过本次调整

avgFps < 24?

res=0.75, density=0.6

avgFps < 30?

density=0.8

avgFps > 50?

res=1.0, density=1.0

保持当前档位

thermal >= 5?

res = min(res, 0.7)

thermal >= 3?

density = min(density, 0.7)

不下发

变化 > 10%?

下发 setResolutionScale / setDensityScale

要点 6:优化前后数据对比。
下面是我在中端机(8 核、GPU 中档)上跑 30 万 splat 的实测对比,同一场景、同一模型,连续运行 5 分钟取均值:

指标优化前(裸跑)优化后(本文方案)提升
平均帧率18 fps42 fps+133%
1% Low 帧率9 fps31 fps+244%
帧率抖动(标准差)8.62.1-76%
5 分钟耗电12.4%7.1%-43%
机身最高温度46.8 ℃41.2 ℃-5.6 ℃
后台 1 分钟耗电2.3%0.1%-96%

可以看到,帧率提升主要来自"先裁剪再排序"(要点 1),而耗电和温度的改善主要来自"温控抢跑 + 后台暂停"(要点 2、3)。抖动的大幅下降则归功于滑动平均 + 滞回 + 冷却三重防抖。

五、小结与边界

这套"裁剪优先 + 温控联动 + 动态分辨率 + 前后台暂停"的组合拳,适合所有在移动端跑 3DGS / 重光栅化、且对发热续航敏感的场景:商品预览、AR 预览、扫描重建查看。它不适用于对画质零容忍的影视级离线渲染(那本就不该在端侧实时跑),也不替你解决算法层面的 splat 压缩——如果模型本身 500 万根没做预处理,再调优也救不回来,先去降采样。落地口诀:先裁后排、温控抢跑、后台必停、降级带滞回。

5.1 方案回顾

把全文的六个要点收拢成一条主线,其实就是三层防线:

  1. 算得少:视锥 + 距离 LOD 先裁剪,只对可见集排序与绘制,把每帧的 CPU 排序和 GPU 光栅化规模压下来(要点 1)。
  2. 降得早:订阅 thermal 等级,HOT(3) 就压密度、WARNING(5) 再压分辨率,抢在系统降频之前主动削负载(要点 2、5)。
  3. 停得干净:切后台立即 pause() 渲染线程,回前台 resume(),状态不丢、电量不流(要点 3)。

而贯穿始终的防抖三件套——滑动平均、滞回阈值、冷却间隔——决定了这套方案是"平滑过渡"还是"疯狂横跳"(要点 4)。很多人代码写对了却体感很差,问题往往就出在这里。

5.2 关键参数速查

参数建议值说明
帧率滑动窗口30 帧太小易受毛刺影响,太大反应迟钝
降级冷却间隔800 ms两次调整的最小间隔,防止连续抖动
滞回阈值10%变化超过该比例才下发,避免反复重分配
低帧率双降线24 fps低于此值同时降分辨率与密度
单降密度线30 fps仅降密度,视觉几乎无感
回升线50 fps留足余量再恢复,避免临界震荡
温控压密度线HOT(3)提前介入,不等系统降频
温控压分辨率线WARNING(5)更激进的兜底手段

5.3 边界与后续

需要明确的是,本文方案解决的是渲染调度层的问题,它不改变 3DGS 本身的算法复杂度。如果你的模型动辄几百万 splat 且未做任何预处理,再精细的降级策略也只是在"糊"和"卡"之间做取舍。真正的下一步应该往上游走:

  • 模型侧:离线做 splat 剪枝、量化与分层 LOD,从源头减少参与渲染的高斯数量;
  • 传输侧:按视距分级加载,远处用低精度子集,靠近再补细节;
  • 渲染侧:探索 GPU 端排序(如基于 compute shader 的基数排序),把 CPU 从排序瓶颈里解放出来。

最后留一句实践心得:调优不是把参数调到极限,而是让系统在用户察觉之前就完成自我调节。 当用户全程没感觉到卡、也没感觉到烫,这套方案就算成功了。

Logo

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

更多推荐