Hotdry.

Article

Supersplat 分层撤销栈与 GSplat 格式解析器架构解析

解析 PlayCanvas Supersplat 如何在 TypeScript 中实现 3D GSplat 格式解析器与分层撤销栈,支撑大场景实时预览与参数微调的工程架构。

2026-05-11web

3D Gaussian Splatting(3DGS)作为新一代辐射场渲染技术,通过海量高斯基元(Splat)表达场景几何与外观。相比传统三角网格,GSplat 数据规模动辄百万级,点云属性包含位置、旋转、缩放、颜色、透明度以及球谐函数系数。编辑这类数据面临两个核心挑战:如何在浏览器中高效解析与渲染超大二进制流;如何在高频操作场景下提供可靠的撤销 / 重做能力。PlayCanvas 开源的 Supersplat 编辑器正是解决这两个问题的工程范本,其核心代码完全采用 TypeScript 编写,运行于 WebGL 环境。本文从源码出发,拆解其 GSplat 格式解析器与分层撤销栈的架构设计。

GSplat 格式解析器:从 PLY 到内存表示

Supersplat 支持加载与导出多种 Splat 格式,其核心解析逻辑封装在 splat-serialize.ts 中。该文件包含约 1400 行代码,处理从 PLY 文件或自定义 .splat 二进制格式到内存数据结构的转换。

GSplat 数据本质上是按顺序排列的高斯基元数组,每个基元由一组属性向量组成。标准 .splat 文件采用二进制布局,按字节偏移量直接存储 Float32 或 Float16 数据。解析器需要完成三件事:根据格式头部识别属性布局;将二进制流映射为 TypedArray 视图;将属性数组封装为可随机访问的语义对象。

在内存表示层面,Supersplat 抽象出 Splat 类作为场景管理单元。每个 Splat 实例持有 splatData 对象,其中包含 numSplats(基元数量)、属性数组(通过 getProp 方法按名称访问)以及 GPU 资源(纹理和变换调色板)。属性名称如 state(Uint8Array)、center(Float32Array,存储 xyz 位置)等与渲染层直接对应。这种设计将解析结果保持在与 GPU 布局一致的状态,避免了 CPU 端与 GPU 端数据格式转换的开销。

对于编辑器而言,解析器还需要支持流式读取与大端序 / 小端序兼容。Supersplat 的解析器实现了版本协商机制,能够识别不同来源生成的 Splat 文件变体,并自动调整字节序与属性偏移。这一能力对支持用户社区各种导出工具生成的素材至关重要。

分层撤销栈:Command Pattern 与异步序列化

撤销系统是编辑器交互体验的核心。Supersplat 的撤销栈实现位于 edit-history.ts,其设计既要处理同步操作,也要安全地与 GPU 异步回读(readback)流水线协作。

Promise Chain 序列化机制

编辑器中最复杂的异步来源是 updatePositions 调用。该函数触发 GPU 回读,将计算后的 Splat 中心位置传回 CPU 端。当用户在界面上快速连续操作(如连续按 Ctrl+Z)时,多个异步回读可能同时在飞行中,导致 sorter 的 centers buffer 数据错乱。

Supersplat 的解决方案是引入 Promise Chain:EditHistory 维护一个 chain: Promise<void> 成员,所有历史修改操作(add、undo、redo)都必须通过 queue 方法入队。queue 方法将传入的异步函数追加到当前 chain 的尾部,确保操作严格串行执行:

queue(fn: () => Promise<void>) {
    const next = this.chain.then(fn);
    this.chain = next.catch((err) => {
        console.error('EditHistory queued operation failed', err);
    });
    return next;
}

这个设计巧妙的点在于它完全透明地解决了竞态问题。调用方无需感知 GPU 回读的时机细节,只需要将异步工作传入 queue,撤销系统自动保证序列化。transform-handler.ts 等模块正是通过暴露的 queue 接口将自身的 GPU 读回操作与历史记录保持同步。

指针式游标管理

EditHistory 使用指针式游标(cursor)管理撤销栈状态:cursor 指向当前已执行到哪一步,而 history 数组包含全部记录。每次 add 操作时,新操作追加到游标位置,同时裁剪掉游标之后的历史(实现重做覆盖);undo 时游标回退并调用操作的 undo 方法;redo 时游标前进并调用 do 方法:

private async _add(editOp: EditOp, suppressOp = false) {
    while (this.cursor < this.history.length) {
        this.history.pop().destroy?.();
    }
    this.history.push(editOp);
    await this._redo(suppressOp);
}

private async _undo() {
    const editOp = this.history[this.cursor - 1];
    await editOp.undo();
    this.cursor--;
    this.events.fire('edit.apply', editOp);
    this.fireEvents();
}

这种实现与常见的双栈(undo stack + redo stack)方案相比,优势在于内存效率更高:新增操作时无需单独清空 redo 栈,而是通过截断实现覆盖。

事件驱动与状态通知

EditHistory 通过 Events 总线向外发布 edit.canUndoedit.canRedo 事件。UI 层(如底栏撤销 / 重做按钮)监听这些事件以更新按钮可用状态。撤销与重做完成后,edit.apply 事件携带具体操作实例,通知渲染层刷新画面。

操作类型体系:EditOp 接口与具体实现

撤销系统定义了 EditOp 接口,所有可撤销操作都实现该接口:

interface EditOp {
    name: string;
    do(): void | Promise<void>;
    undo(): void | Promise<void>;
    destroy?(): void;
}

Supersplat 在 edit-ops.ts 中实现了十余种操作类型,按功能分为以下几类。

状态位操作(StateOp)

StateOp 处理 Splat 的选择、隐藏、删除等状态管理。每个 Splat 的 state 属性是 Uint8Array,使用位掩码表示多种状态:第 0 位为 selected(已选中)、第 1 位为 locked(已锁定)、第 2 位为 deleted(已删除)。StateOp 接受位操作类型(SET、CLEAR、TOGGLE)和受影响索引范围,在 doundo 中自动生成反向操作:

private apply(op: BitOp) {
    switch (op) {
        case BitOp.SET:
            this.ranges.forEach((i) => { state[i] |= mask; });
            break;
        case BitOp.CLEAR:
            this.ranges.forEach((i) => { state[i] &= ~mask; });
            break;
        case BitOp.TOGGLE:
            this.ranges.forEach((i) => { state[i] ^= mask; });
            break;
    }
}

基于 StateOp 派生出 SelectAllOpSelectNoneOpSelectInvertOpDeleteSelectionOp 等具体操作。值得注意的是,这些操作通过 IndexRanges 表达受影响的 Splat 子集,而非全量索引。IndexRanges 支持通过断言函数(predicate)动态计算范围,也接受预先计算的 Uint32Array,在大规模场景下避免遍历全量 Splat。

实体变换操作(EntityTransformOp)

EntityTransformOp 处理整个 Splat 实体的位移、旋转与缩放。操作记录变换前后的 Transform 对象,执行时调用 splat.move(position, rotation, scale) 更新实体层级变换。这种操作的粒度是场景管理级别,适用于导入多个 PLY 文件后调整它们之间的相对关系。

个体 Splat 变换操作(SplatsTransformOp)

相比实体级变换,SplatsTransformOp 处理被选中 Splat 子集的几何变换。它采用变换调色板(Transform Palette)机制优化内存:多个 Splat 共享同一组变换参数,通过纹理中的索引引用调色板条目。变换时,SplatsTransformOp 更新调色板映射并重新分配 GPU 资源:

async do() {
    // 更新 Splat 变换纹理中的调色板索引
    for (let i = 0; i < state.length; ++i) {
        if (state[i] === State.selected) {
            indices[i] = paletteMap.get(indices[i]);
        }
    }
    // 重新分配调色板
    splat.transformPalette.alloc(paletteMap.size);
    // 更新调色板内容
    paletteMap.forEach((newIdx, oldIdx) => {
        transformPalette.getTransform(oldIdx, mat);
        mat.mul2(transform, mat);
        transformPalette.setTransform(newIdx, mat);
    });
    await splat.updatePositions();
}

undo 时反向应用映射表并恢复调色板大小。由于涉及 GPU 资源分配,这两个方法都是异步的,必须通过 EditHistory.queue 入队序列化执行。

颜色调整操作(SetSplatColorAdjustmentOp)

SetSplatColorAdjustmentOp 记录 Splat 的调色、饱和度、亮度等视觉参数的前后状态。Supersplat 的 UI 允许用户实时调节这些参数,每次调节完成后记录操作快照。该操作的优势在于存储的是增量状态而非全量属性,减少了内存占用。

组合操作(MultiOp)

MultiOp 将多个 EditOp 聚合为一个逻辑操作。在 doundo 时顺序执行或逆序回退所有子操作。这对于需要同时修改多个方面的用户操作非常有用,例如「删除选中 Splat 并重置视图」可以打包为单一撤销单位。

GPU 同步与内存管理

Supersplat 撤销系统的另一个工程难点在于 GPU 同步。Splat 数据存储在 GPU 纹理中,CPU 端读取需要通过 gl.readPixels 或 WebGL 像素回读机制。这类操作延迟不可预测,且不同浏览器实现差异显著。

updatePositions 是关键同步点。它触发 GPU 计算并将 Splat 中心点坐标回读到 CPU。一旦撤销栈中的操作依赖此数据,必须等待回读完成后才能继续执行后续操作。Promise Chain 机制在此处发挥了屏障(barrier)作用:后续的 undo/redo 操作会等待前一个 updatePositions 完全结算。

内存管理方面,每个 EditOp 可以定义 destroy 生命周期方法。当操作被裁剪(被新操作覆盖)或显式清除时,EditHistory 调用 destroy 释放持有的资源(如释放变换调色板条目)。clear 方法清空整个撤销栈时,遍历所有操作触发销毁回调,防止内存泄漏:

clear() {
    return this.queue(() => {
        this.history.forEach((editOp) => {
            editOp.destroy?.();
        });
        this.history = [];
        this.cursor = 0;
        this.fireEvents();
        return Promise.resolve();
    });
}

对于 Splat 删除场景,removeForSplat 方法遍历撤销历史,移除所有引用已删除 Splat 的操作,并相应调整游标位置。这避免了悬空引用导致的内存泄漏或后续操作崩溃。

工程实践参数与扩展性考量

在生产环境中引入类似架构时,有几个关键参数值得关注。

Promise Chain 容量控制:Supersplat 的链式设计保证了操作的严格序列化,但也意味着高频操作可能积累大量待处理的 Promise。典型 Web 应用中,用户每秒最多触发数十次操作,链长可控。若面对更高的操作频率,可以考虑在 queue 方法中加入节流(throttle)或批量合并(batch)策略。

位操作与状态机设计:使用位掩码管理 Splat 状态是内存高效的选择。在 TypeScript 中,可通过 Uint8Array 的按位运算实现状态切换,内存占用仅为布尔数组的八分之一。若需要更多状态位,可以扩展到 Uint16ArrayUint32Array

GPU 资源分配策略SplatsTransformOp 的调色板分配是按需扩展的。当连续执行多次变换时,调色板会不断重新分配,带来 GPU 内存碎片化风险。实际项目中可考虑实现调色板合并策略或固定大小池化方案。

异步操作的错误处理EditHistory.queue 方法捕获了 Promise 链中的错误并输出到控制台,但不会中断后续操作。这种静默失败策略适合编辑器场景 —— 单一操作失败不应阻塞用户的整体编辑流程。若需要更强的可靠性,可以在 queue 中加入重试逻辑或暂停机制。

小结

Supersplat 的撤销系统展示了一套针对实时 3D 编辑器的工程化方案:Command Pattern 提供统一的操作抽象;Promise Chain 序列化异步 GPU 回读,根除竞态条件;位掩码状态管理兼顾内存效率与语义表达;生命周期方法确保 GPU 资源正确释放。这些设计决策相互配合,共同支撑起大场景 Splat 数据实时预览与参数微调的流畅体验。

资料来源:PlayCanvas Supersplat 开源项目(https://github.com/playcanvas/supersplat)、PlayCanvas 开发者文档(https://developer.playcanvas.com/user-manual/gaussian-splatting/editing/supersplat/interface/)。

web

内容声明:本文无广告投放、无付费植入。

如有事实性问题,欢迎发送勘误至 i@hotdrydog.com