一、灰色预览不是渲染器坏了

SplatToneLab 最初只是一个 3DGS 室内扫描预览页:读取 atrium_scan.ply,先用 DC 项显示灰度近似,再把三阶球谐系数转换成当前相机方向下的 RGB。模型共 412,672 个 Gaussian,三阶球谐每个颜色通道 16 个系数,解包后的系数缓冲区是 79.2 MB。功能能跑,但只要在 ShBakePage 连续拖动视角,页面就会出现 436 ms 的长帧;用户第二次点“重算颜色”时,第一次计算还会把旧相机方向的结果覆盖回来。

这类故障很容易被误判成 GPU 上传慢。HiLog 却给了另一条线索:uploadColorBuffer() 只花 37 ms,真正挤占主线程的是球谐基函数求值、系数乘加和颜色钳制。更麻烦的是,直接把整个 ArrayBuffer 交给 TaskPool 后,主线程上的 Float32Array 视图长度变成了 0。那不是内存泄漏,而是缓冲区被转移后发生了所有权迁移。

这次改造没有追求一个“并发越高越快”的数字。目标被限定为四个可验证条件:任务 SH-2313 只允许 generation 18 的结果提交;412,672 个点固定切为 4 片,每片 103,168 点;计算期间 UI 最大帧耗时不超过 24 ms;退出页面后所有结果缓冲区、任务引用和计时器都能释放。

二、先把数据所有权写在代码旁边

TaskPool 适合独立、CPU 密集、可序列化的计算。HarmonyOS 文档明确说明,ArrayBuffer 作为 TaskPool 参数时默认按转移方式传递,原线程不能继续使用同一块缓冲区;如果需要共享或保留,应重新设计数据结构,而不是把 detached buffer 当成偶现异常。本项目因此不转移 PLY 的唯一源缓冲区,而是在解析完成后生成 4 个计算分片。每个分片只含当前任务需要的球谐系数和相机方向,主线程仍保留轻量模型元数据。

第一段代码解决“子线程究竟拿什么”的问题。@Concurrent 函数不引用 UI 组件,也不捕获页面实例;它接收一块独占的系数缓冲区,输出一块 RGB 缓冲区。为了让图中的代码、日志和结果可一一核对,分片长度、SH 阶数和任务代次都显式传入。

import { taskpool } from '@kit.ArkTS';

interface ShChunkResult {
  generation: number;
  chunkIndex: number;
  rgb: ArrayBuffer;
}

@Concurrent
function bakeShChunk(coeffBuffer: ArrayBuffer, pointCount: number,
  chunkIndex: number, generation: number, view: number[]): ShChunkResult {
  const coeff = new Float32Array(coeffBuffer);
  const rgb = new Float32Array(pointCount * 3);
  const basis = evalSh3(view[0], view[1], view[2]); // 16 个基函数
  for (let p = 0; p < pointCount; p++) {
    for (let c = 0; c < 3; c++) {
      let value = 0.5;
      const base = p * 48 + c * 16;
      for (let k = 0; k < 16; k++) value += coeff[base + k] * basis[k];
      rgb[p * 3 + c] = Math.min(1, Math.max(0, value));
    }
  }
  return { generation, chunkIndex, rgb: rgb.buffer };
}

这里有两个容易踩的边界。其一,coeffBuffer 进入任务后由子线程拥有,页面不能再读取它;所以调用方传入的是按点范围复制得到的独立 slice,而不是 model.shBuffer 本体。其二,任务结果回到宿主线程时仍要检查 generation。取消只能尽量阻止尚未执行的任务,已经开始的 CPU 循环未必在点击瞬间停止,业务层必须拒绝迟到结果。页面销毁时也不能等待旧结果再做清理,否则导航返回会被计算尾巴拖住。

三、四片并行不等于四次提交

第二个问题出现在我把任务切成四片以后:每完成一片就调用一次 GPU 更新,长帧从一次变成了四次,视觉上反而更抖。球谐预计算的正确提交单位不是“分片”,而是“同一代次的完整颜色数组”。分片只是调度单位,提交仍需原子化。

下面的 ShBakeCoordinator 负责三个约束:新请求先提升 generation 并取消队列里的旧任务;四个结果按 chunkIndex 落入固定槽位,不以完成顺序排序;只有 4/4 且代次仍有效时才拼接并提交。重复点击不会叠加第五个任务组。

class ShBakeCoordinator {
  private generation: number = 17;
  private tasks: taskpool.Task[] = [];
  private parts: Array<ArrayBuffer | undefined> = new Array(4);

  async start(model: SplatModel, view: number[]): Promise<void> {
    const gen = ++this.generation; // 本次为 generation 18
    this.cancelQueued();
    this.parts = new Array(4);
    const count = 103168;
    for (let index = 0; index < 4; index++) {
      const slice = model.copyShChunk(index * count, count);
      const task = new taskpool.Task(
        bakeShChunk, slice, count, index, gen, view
      );
      this.tasks.push(task);
      taskpool.execute(task, taskpool.Priority.HIGH)
        .then((result: ShChunkResult) => this.accept(result, model));
    }
  }

  private accept(result: ShChunkResult, model: SplatModel): void {
    if (result.generation !== this.generation) return;
    this.parts[result.chunkIndex] = result.rgb;
    if (this.parts.filter((p) => p !== undefined).length !== 4) return;
    model.uploadColorBuffer(joinRgb(this.parts));
    hilog.info(0x2200, 'ShBake',
      `SH-2313 gen=${result.generation} chunks=4/4 state=GPU_READY`);
  }

  cancelQueued(): void {
    this.tasks.forEach((task) => { if (!task.isDone()) taskpool.cancel(task); });
    this.tasks = [];
  }
}

copyShChunk() 看似多了一次复制,却换来明确的所有权和可控峰值。原实现把 79.2 MB 系数、四个结果以及旧代次结果同时留在内存里,峰值接近 286 MB;改为“复制一片、转移一片、结果到齐即拼接”,实测峰值 142.6 MB。若模型更大,不应机械增加分片数,因为任务对象、序列化元数据和结果拼接也有成本。本 Demo 的四片来自 412,672 能被 103,168 整除,生产代码最后一片必须按剩余点数计算。

四、取消是一道闸门,不是时光倒流

测试时我故意在进度 50% 时旋转相机并再次点击。旧任务有两片已经完成,两片正处在运行态。taskpool.cancel() 成功拦住了尚未调度的任务,却不能保证正在计算的函数立刻中断。这正是 generation 不能省略的原因:18 代启动后,17 代的任何 then 回调都只负责释放返回缓冲,不得改变 @State、不得上传 GPU、也不得把页面状态改成完成。

第三段代码把生命周期收口放回页面。页面只订阅协调器快照,不持有 ArrayBuffer;退出时先使代次失效,再取消任务和清空状态监听。这样即使回调晚到,也找不到合法的提交窗口。

@Entry
@Component
struct ShBakePage {
  @State status: string = 'READY';
  @State progress: number = 0;
  @State maxFrameMs: number = 0;
  private coordinator = new ShBakeCoordinator();
  private alive: boolean = false;

  aboutToAppear(): void {
    this.alive = true;
    this.coordinator.onProgress((done: number, gen: number) => {
      if (!this.alive || gen !== 18) return;
      this.progress = done * 25;
      this.status = done === 4 ? 'GPU_READY' : 'BAKING_SH';
    });
  }

  aboutToDisappear(): void {
    this.alive = false;
    this.coordinator.invalidateGeneration();
    this.coordinator.cancelQueued();
    this.coordinator.dispose();
  }
}

这里没有在 aboutToDisappear() 中把状态写回 READY,因为组件即将离开,额外写状态只会触发无意义的刷新。真正需要释放的是任务引用、进度回调和暂存结果。还要注意 task.isDone() 与 cancel() 之间存在竞争窗口,取消失败不应升级成用户可见错误;最终正确性由代次校验保证,取消只是减少无效计算。

五、用帧预算而不是总耗时验收

优化前总计算耗时 436 ms,看起来比改造后的 318 ms 只快了一百多毫秒;体验差异却很大。旧版 436 ms 全压在 UI 线程,拖动、返回和按钮反馈一起冻结。新版 TaskPool 计算 281 ms,4 片结果拼接 11 ms,GPU 上传 26 ms,期间 UI 最大帧耗时 24 ms,页面仍能显示进度并响应取消。

验收脚本固定做三轮:冷启动加载 atrium_scan.ply;在 25% 与 50% 各触发一次重算;在 75% 直接返回上一页。最终一轮记录为任务 SH-2313、generation 18、chunks=4/4、points=412672、shDegree=3、compute=281ms、merge=11ms、upload=26ms、peak=142.6MB、lateDropped=3、loop=1,状态 GPU_READY。手机页上的相机方向是 (0.21, -0.34, 0.92),与四个分片使用的 view 数组一致。

lateDropped=3 不是异常,它说明故障注入确实制造了三个迟到回调,而闸门没有让它们污染可见状态。若这个数字始终为 0,反而要检查测试是否真的覆盖了重算和退出竞争。另一个门槛是 activeTasks=0:只看画面正确而不看页面离开后的任务账,会把电量消耗和内存尾巴留到下一个场景。

六、这套拆分何时不值得

少于五万个点、只用 DC 颜色或只算一次的模型,直接在加载阶段完成通常更简单;TaskPool 的任务创建、参数转移和拼接不是免费的。需要持续随视角重算时,也不要把每一帧都变成新任务。这个 Demo 在相机静止 80 ms 后才生成新代次,并对方向向量做量化,避免细微抖动制造无意义重算。

如果后续改成 Native 渲染器,球谐求值可以下沉到 shader;此时本文的价值不在那段乘加循环,而在“源数据所有权、调度分片、结果代次、提交原子性”四层合同。它们同样适用于点云法线、缩略图特征和大批量颜色空间转换。

参考资料:TaskPool 简介、TaskPool 使用规范、Sendable 使用指南。

Logo

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

更多推荐