阅读视图

发现新文章,点击刷新页面。

从桌面端到高性能三维:大点云渲染的两种 Electron 架构实战

当 Three.js 遇上百万级点云,Web 内存与计算双双告急。我们用 C++ 扛起计算,用两种架构打通数据共享——同进程 N-API 与跨进程 gRPC+mmap,究竟谁更胜一筹?

引言

点云是三维世界中最原始、最直观的数据形式。一个中等规模的激光雷达扫描,动辄数百万乃至上亿个点。在 Electron 中直接使用 Three.js 加载 PLY/PCD 文件,往往瞬间耗尽内存,帧率跌至个位数。

根本原因在于:JavaScript 的单线程与 GC 压力,以及大块几何数据在 JS 堆中的双重拷贝。为了破局,业界普遍将计算密集、内存敏感的任务下沉到 C++,然后通过高效的数据共享机制将处理好的顶点数据传递给 Three.js。

本文将深入剖析两种架构方案:

  1. 同进程 N-API:C++ 代码以 Node 原生模块的形式直接运行在 Electron 主进程或渲染进程中。
  2. 跨进程 gRPC + mmap:C++ 作为独立后台服务,通过 gRPC 通信,通过 mmap 共享内存零拷贝交换数据。

我们不仅会对比优劣,更会给出核心代码实现,让你能真正落地到自己的项目中。


一、痛点与需求

以一个大点云项目为例:

  • 点云文件:.las 格式,包含 2000 万个点,每个点有 XYZ、RGB、强度等属性。
  • 需求:实时旋转/缩放,无卡顿;支持动态筛选(按强度、分类值)。
  • 瓶颈:
    • JS 解析 2000 万个点 → 内存爆炸(每点至少 24 字节,仅位置就需要 480MB)
    • Three.js BufferGeometry 创建过程会再拷贝一次 → 内存翻倍
    • 主线程解析 + 渲染 → UI 冻结

因此,必须:

  1. C++ 负责解析、滤波、LOD 生成
  2. C++ 与 JS 共享同一块内存,避免数据拷贝。
  3. Three.js 直接消费共享内存中的顶点数据

二、方案一:同进程 N-API(Node Addon)

2.1 架构图

┌─────────────────────────────────────────┐
│           Electron Main/Renderer        │
│  ┌───────────────────────────────────┐  │
│  │            React UI               │  │
│  └───────────────┬───────────────────┘  │
│                  │ 调用 N-API 导出函数   │
│  ┌───────────────▼───────────────────┐  │
│  │       C++ Addon (N-API)           │  │
│  │  - 点云解析                        │  │
│  │  - 滤波/采样                       │  │
│  │  - LOD 生成                        │  │
│  └───────────────┬───────────────────┘  │
│                  │ 返回 ArrayBuffer     │
│  ┌───────────────▼───────────────────┐  │
│  │       Three.js Renderer           │  │
│  │  BufferAttribute 直接引用 ArrayBuffer│
│  └───────────────────────────────────┘  │
└─────────────────────────────────────────┘

2.2 核心实现:C++ Addon 返回可转移的 ArrayBuffer

步骤 1:编写 C++ 点云解析函数

使用 N-API 创建 ArrayBuffer,填充顶点数据后返回给 JS。

#include <napi.h>
#include <vector>
#include <fstream>
#include "lasreader.hpp"  // LASlib 等

struct Point {
    float x, y, z;
    uint8_t r, g, b;
};

Napi::Value ParsePointCloud(const Napi::CallbackInfo& info) {
    Napi::Env env = info.Env();
    std::string filepath = info[0].As<Napi::String>().Utf8Value();

    // 1. C++ 中解析点云,获取点数量
    LASreader* reader = LASreader::open(filepath.c_str());
    uint64_t num_points = reader->npoints;
    
    // 2. 计算内存大小(每个点 3*float + 3*uint8 = 12+3 = 15 字节,对齐到 16 字节)
    size_t buffer_size = num_points * sizeof(Point);
    
    // 3. 创建 N-API ArrayBuffer,内存由 C++ 分配(可使用 napi_create_external_arraybuffer 避免额外拷贝)
    void* data = malloc(buffer_size);
    Point* points = static_cast<Point*>(data);
    
    // 4. 填充数据
    size_t idx = 0;
    while (reader->read_point()) {
        points[idx].x = reader->point.get_x();
        points[idx].y = reader->point.get_y();
        points[idx].z = reader->point.get_z();
        points[idx].r = reader->point.get_r();
        points[idx].g = reader->point.get_g();
        points[idx].b = reader->point.get_b();
        ++idx;
    }
    reader->close();
    delete reader;

    // 5. 创建 ArrayBuffer,并绑定释放回调
    Napi::ArrayBuffer buffer = Napi::ArrayBuffer::New(env, data, buffer_size,
        [](Napi::Env env, void* finalize_data) {
            free(finalize_data);
        });
    
    // 6. 返回给 JS(同时可附带点数量等元数据)
    Napi::Object result = Napi::Object::New(env);
    result.Set("buffer", buffer);
    result.Set("numPoints", Napi::Number::New(env, num_points));
    return result;
}

Napi::Object Init(Napi::Env env, Napi::Object exports) {
    exports.Set("parsePointCloud", Napi::Function::New(env, ParsePointCloud));
    return exports;
}
NODE_API_MODULE(pointcloud_addon, Init)

步骤 2:React + Three.js 中消费 ArrayBuffer

// 在渲染进程中(确保 nodeIntegration 开启或通过 preload 暴露)
const addon = require('pointcloud-addon');

async function loadPointCloud(filePath) {
    const { buffer, numPoints } = addon.parsePointCloud(filePath);
    
    // 关键:将 ArrayBuffer 转为 Float32Array 和 Uint8Array 视图,但不复制数据
    const positions = new Float32Array(buffer, 0, numPoints * 3);
    const colors = new Uint8Array(buffer, numPoints * 12, numPoints * 3);
    
    // 创建 Three.js BufferGeometry
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3, true));
    
    // 使用 PointsMaterial 渲染
    const material = new THREE.PointsMaterial({ vertexColors: true, size: 0.1 });
    const points = new THREE.Points(geometry, material);
    scene.add(points);
}

2.3 优缺点分析

优点 缺点
✅ 零拷贝:C++ 分配的 ArrayBuffer 直接被 Three.js 使用,无内存复制 ❌ 主线程阻塞:若直接在渲染进程调用会卡 UI(但可通过 worker_threads 解决)
✅ 延迟极低:函数调用开销微秒级 ❌ 崩溃风险:C++ Addon 内存越界会导致整个 Electron 进程崩溃
✅ 开发简单:无需跨进程通信,调试方便 ❌ Node 版本绑定:需要针对 Electron 的 Node 版本编译原生模块
✅ 部署单一:只有一个 .exe/.app 文件 ❌ 内存释放不可控:依赖 GC 触发 finalize,大对象可能延迟释放

关于 UI 阻塞的解决方案

直接在渲染进程中调用 addon.parsePointCloud 会同步执行 C++ 代码,若解析耗时超过 16ms,页面就会掉帧。正确的做法是将解析任务放到主进程的 worker_threads 中执行,解析完成后通过 postMessage 将 ArrayBuffer 传回渲染进程(结构化克隆会转移所有权,依然零拷贝)。

javascript

复制下载

// 主进程中创建一个 worker 线程
const { Worker } = require('worker_threads');

const worker = new Worker(`
  const { parentPort } = require('worker_threads');
  const addon = require('pointcloud-addon');
  
  parentPort.on('message', (filePath) => {
    const { buffer, numPoints } = addon.parsePointCloud(filePath);
    // 直接转移 ArrayBuffer 所有权,无需拷贝
    parentPort.postMessage({ buffer, numPoints }, [buffer]);
  });
`, { eval: true });

worker.on('message', ({ buffer, numPoints }) => {
  // 通过 IPC 发送给渲染进程
  mainWindow.webContents.send('pointcloud-data', buffer, numPoints);
});

渲染进程收到后,直接使用 new Float32Array(buffer) 创建视图即可。这样 C++ 解析完全在后台线程,UI 永不阻塞

注意:worker_threads 是 Node.js 的线程,不是 Web Worker。Web Worker 无法加载原生模块,因此不适用于此场景。

C++ addon 调用放在 worker_threads 中执行 能避免 程序崩溃吗?

不能!

为什么 worker_threads 也无法隔离崩溃?

即便将 C++ addon 调用放在 worker_threads 中执行,由于 worker 线程与主进程共享同一内存空间,原生代码的崩溃依然会连带杀死整个进程。Worker 线程的隔离是 JS 层面的,而非操作系统级的进程隔离。


三、方案二:独立 C++ 服务 + gRPC + mmap 共享内存

3.1 架构图

┌─────────────────────────┐         gRPC(控制面)        ┌─────────────────────────┐
│     Electron 主进程      │◄────────────────────────────►│    独立 C++ 服务         │
│  ┌─────────────────────┐ │                              │  - 点云解析              │
│  │  gRPC Client        │ │                              │  - 滤波/重采样           │
│  │  (Node.js)          │ │                              │  - LOD 生成              │
│  └──────────┬──────────┘ │                              │  - 写入 mmap             │
│             │ IPC         │                              └────────────┬────────────┘
│  ┌──────────▼──────────┐ │                                           │
│  │  Renderer (React)   │ │                              ┌────────────▼────────────┐
│  │  - Three.js         │ │   mmap 共享内存(数据面)     │   /dev/shm/pointcloud   │
│  │  - 读取 mmap 数据    │◄─────────────────────────────►│   (顶点 + 索引 + 元数据)  │
│  └─────────────────────┘ │                              └─────────────────────────┘
└─────────────────────────┘

3.2 核心实现:三步骤打通数据流

3.2.1 C++ 服务:解析点云并写入 mmap

使用 boost::interprocess 或 POSIX shm_open + mmap。为了跨平台,推荐使用 boost

#include <boost/interprocess/shared_memory_object.hpp>
#include <boost/interprocess/mapped_region.hpp>
#include <atomic>

struct SharedPointCloudHeader {
    std::atomic<uint64_t> version{0};
    std::atomic<bool> ready{false};
    uint64_t num_points;
    uint64_t data_offset;  // 顶点数据起始偏移
};

class PointCloudServer {
public:
    void LoadAndShare(const std::string& las_path) {
        // 1. 解析点云到内存 vector
        std::vector<Point> points = ParseLAS(las_path);
        
        // 2. 计算总大小
        size_t header_size = sizeof(SharedPointCloudHeader);
        size_t data_size = points.size() * sizeof(Point);
        size_t total_size = header_size + data_size;
        
        // 3. 创建共享内存对象
        boost::interprocess::shared_memory_object shm(
            boost::interprocess::open_or_create,
            "pointcloud_shm",
            boost::interprocess::read_write
        );
        shm.truncate(total_size);
        
        // 4. 映射到本进程地址空间
        boost::interprocess::mapped_region region(shm, boost::interprocess::read_write);
        
        // 5. 写入 header
        SharedPointCloudHeader* header = static_cast<SharedPointCloudHeader*>(region.get_address());
        header->num_points = points.size();
        header->data_offset = header_size;
        header->version.fetch_add(1, std::memory_order_release);
        
        // 6. 写入顶点数据
        void* data_ptr = static_cast<char*>(region.get_address()) + header_size;
        memcpy(data_ptr, points.data(), data_size);
        
        // 7. 标记 ready
        header->ready.store(true, std::memory_order_release);
    }
    
    // gRPC 服务接口:返回共享内存名称和大小
    grpc::Status LoadModel(grpc::ServerContext*, const LoadRequest* req, LoadResponse* resp) {
        LoadAndShare(req->file_path());
        resp->set_shm_name("pointcloud_shm");
        resp->set_shm_size(total_size);
        resp->set_data_offset(header_size);
        return grpc::Status::OK;
    }
};

3.2.2 Electron 主进程:gRPC 调用获取元数据

// main.js 中
const grpc = require('@grpc/grpc-js');
const protoLoader = require('@grpc/proto-loader');
const { ipcMain } = require('electron');

const packageDefinition = protoLoader.loadSync('pointcloud.proto');
const proto = grpc.loadPackageDefinition(packageDefinition);
const client = new proto.PointCloudService('localhost:50051', grpc.credentials.createInsecure());

ipcMain.handle('load-pointcloud', async (event, filePath) => {
    return new Promise((resolve, reject) => {
        client.LoadModel({ file_path: filePath }, (err, response) => {
            if (err) reject(err);
            else resolve({
                shmName: response.shm_name,
                shmSize: response.shm_size,
                dataOffset: response.data_offset
            });
        });
    });
});

3.2.3 渲染进程:通过 mmap-io 读取共享内存并传给 Three.js

// 渲染进程中(通过 preload 暴露的 API)
const mmap = require('@cathodique/mmap-io');
const fs = require('fs');

async function loadAndRender(filePath) {
    // 1. 通过 IPC 触发 C++ 服务加载
    const { shmName, shmSize, dataOffset } = await window.electronAPI.loadPointcloud(filePath);
    
    // 2. 打开共享内存(Linux /dev/shm,Windows 不同)
    const fd = fs.openSync(`/dev/shm/${shmName}`, 'r');
    const buffer = mmap.map(fd, mmap.PROT_READ, mmap.MAP_SHARED, shmSize, 0);
    
    // 3. 解析 header(前 24 字节)
    const version = buffer.readBigUInt64LE(0);
    const ready = buffer.readUInt8(8) === 1;
    const numPoints = Number(buffer.readBigUInt64LE(16));
    
    if (!ready) throw new Error('Data not ready');
    
    // 4. 从 dataOffset 位置读取顶点数据
    const pointSize = 32;  // 假设 Point 结构体大小
    const positions = new Float32Array(numPoints * 3);
    const colors = new Uint8Array(numPoints * 3);
    
    for (let i = 0; i < numPoints; i++) {
        const base = dataOffset + i * pointSize;
        positions[i*3] = buffer.readFloatLE(base);
        positions[i*3+1] = buffer.readFloatLE(base + 4);
        positions[i*3+2] = buffer.readFloatLE(base + 8);
        colors[i*3] = buffer.readUInt8(base + 12);
        colors[i*3+1] = buffer.readUInt8(base + 13);
        colors[i*3+2] = buffer.readUInt8(base + 14);
    }
    
    // 5. 创建 Three.js 几何体(注意:这里从 buffer 拷贝到了新 ArrayBuffer,可优化?见下文)
    const geometry = new THREE.BufferGeometry();
    geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3, true));
    
    const points = new THREE.Points(geometry, new THREE.PointsMaterial({ vertexColors: true }));
    scene.add(points);
}

性能陷阱:上面代码中,positionscolors 是从 mmap buffer 中逐点读取并创建的新 Float32Array,这仍然存在一次拷贝。要实现真正的零拷贝,需要让 Three.js 直接使用 mmap 映射的原始 buffer。但 Three.js 的 BufferAttribute 只接受 ArrayBufferBuffer 视图,并且要求该内存生命周期与几何体一致。我们可以利用 SharedArrayBuffer 或者直接传递 mmap 得到的 Buffer 对象,只要保证在几何体销毁前不被 unmap。

// 零拷贝版本:直接使用 mmap 返回的 Buffer 创建 Float32Array 视图
const totalFloats = numPoints * 3;
const positionsView = new Float32Array(buffer, dataOffset, totalFloats);
const colorsView = new Uint8Array(buffer, dataOffset + totalFloats * 4, numPoints * 3);
geometry.setAttribute('position', new THREE.BufferAttribute(positionsView, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colorsView, 3, true));
// 注意:需要确保 buffer 在几何体使用期间一直保持映射,不能提前 unmap

3.3 优缺点分析

优点 缺点
进程隔离:C++ 崩溃不影响 Electron UI 架构复杂:需要管理 C++ 服务的生命周期
非阻塞渲染:解析在后台进行,UI 可显示进度 部署麻烦:需打包两个可执行文件
真正零拷贝:mmap 让多进程共享同一物理内存 跨平台兼容性:Windows 下 mmap 行为不同,需封装
可扩展:未来可升级为远程服务,支持多机集群 调试困难:gRPC + mmap 联合调试工具链不成熟
内存可控:C++ 服务可独立释放内存 延迟略高:首次加载需 gRPC 调用(~1ms)

适用场景:点云规模极大(5000 万点以上),需要后台预处理、多任务排队,且对 UI 流畅度要求严苛。


四、核心问题:Buffer 如何从 C++ 共享到 Three.js?

无论哪种方案,最终目标都是让 Three.js 的 BufferAttribute 能直接访问 C++ 中分配的内存,避免复制。两种方案的技术本质:

  • N-API 方案:C++ 通过 napi_create_external_arraybuffer 分配内存,JS 拿到 ArrayBuffer 后,Three.js 可直接创建 BufferAttribute内存所有权归 JS(GC 时调用 free)。
  • mmap 方案:C++ 与 JS 通过操作系统共享内存机制映射同一块物理内存,JS 侧通过 BufferSharedArrayBuffer 访问。内存所有权归 OS,双方均可读写。

技术细节对比

方面 N-API 外部 ArrayBuffer mmap 共享内存
数据拷贝次数 0 次(C++ 直接写入 ArrayBuffer 内存) 0 次(双方映射同一页)
内存释放 由 JS GC 触发 finalize 回调 显式调用 munmap 或进程退出时释放
并发安全 单进程,无需额外同步 多进程,必须使用原子操作或互斥锁
跨语言友好 仅限 Node.js 环境 任何支持 POSIX API 的语言
最大数据量 受 V8 堆限制(64 位下约 4GB) 受物理内存 + 操作系统限制
实现复杂度 低(N-API 标准接口) 高(需处理权限、命名冲突、多进程同步)

结论:对于绝大多数桌面端点云应用,N-API 方案已经足够,且更简单。只有当点云超过 4GB 或需要多进程同时访问时才考虑 mmap。


五、实战决策:我应该选哪种?

5.1 决策树

是否单点云超过 2GB?
├─ 是 → mmap 方案(突破 V8 堆限制)
└─ 否 → 是否需要支持多进程并发访问?
    ├─ 是 → mmap 方案
    └─ 否 → 是否可接受 C++ 模块崩溃导致 Electron 闪退?
        ├─ 是 → N-API 方案(最简单)
        └─ 否 → mmap 方案(进程隔离更安全)

5.2 混合方案:按需选择

实际项目中,可采用双模式:默认使用 N-API(性能最优),当检测到点云过大时,降级为独立服务模式。

async function loadPointCloud(filePath) {
    const fileSize = getFileSize(filePath);
    if (fileSize < 1e9) { // < 1GB
        return loadWithNapi(filePath);
    } else {
        return loadWithGrpcMmap(filePath);
    }
}

好的,我将把“如何实现内存释放”和“clear 如何触发调用”这两个回答整合成一篇完整的技术说明,保持逻辑连贯,避免重复。


六, 跨进程方案(gRPC + mmap)中的内存释放与触发机制

gRPC + mmap 架构中,共享内存的生命周期由 C++ 服务端和 Electron 客户端共同管理。内存释放不是单个操作,而是一套需要双方配合的流程。下面分两部分阐述:释放操作本身释放的触发时机

6.1、内存释放的三大步骤(“怎么释放”)

C++ 服务端需要依次执行以下三个系统调用,才能彻底销毁一块共享内存:

步骤 函数 作用 备注
1 munmap 解除当前进程对共享内存的映射 调用后本进程不能再访问该内存
2 close 关闭由 shm_open 获得的文件描述符 回收进程内的句柄资源
3 shm_unlink 删除共享内存对象的名称 类似 unlink 删除文件;当所有进程都解除映射后,OS 才真正回收物理内存

重要shm_unlink 只是删除了共享内存的“名字”。即使调用了它,如果还有其它进程(如 Electron)仍然映射着这块内存,其内容依然有效。只有所有进程都执行了 munmap 并关闭了引用,操作系统才会回收物理内存。这保证了 Electron 使用期间数据的稳定性。

下面是一个典型的 clear() 方法实现:

#include <sys/mman.h>   // munmap, shm_unlink
#include <unistd.h>     // close

class SharedMemoryCache {
public:
    void clear(const std::string& shm_name) {
        // 1. 解除映射
        if (mapped_ptr) {
            munmap(mapped_ptr, shm_size);
            mapped_ptr = nullptr;
        }
        // 2. 关闭文件描述符
        if (shm_fd != -1) {
            close(shm_fd);
            shm_fd = -1;
        }
        // 3. 删除共享内存对象
        if (!shm_name.empty()) {
            shm_unlink(shm_name.c_str());
        }
        shm_size = 0;
    }
private:
    void* mapped_ptr = nullptr;
    int shm_fd = -1;
    size_t shm_size = 0;
};

Electron 侧也要解映射
在 Node.js 中,使用 @cathodique/mmap-io 等库时,需要显式调用 mmap.unmap(buffer) 并关闭文件描述符。建议监听进程退出事件做兜底清理:

process.on('exit', () => {
    if (mmapBuffer) mmap.unmap(mmapBuffer);
    if (fd) fs.closeSync(fd);
});

6.2、clear() 的触发方式(“何时调用”)

C++ 服务端的 clear() 不会自动执行,必须由明确的逻辑触发。有三种主要方式:

Electron 主动请求释放(最推荐)

通过 gRPC 暴露 Release 接口,让 Electron 在不再需要某块共享内存时主动调用。

定义 proto

service PointCloudService {
  rpc LoadModel(LoadRequest) returns (LoadResponse);
  rpc ReleaseModel(ReleaseRequest) returns (ReleaseResponse);
}
message ReleaseRequest { string shm_name = 1; }

Electron 调用(例如用户关闭点云窗口时):

await window.electronAPI.releaseModel('pointcloud_shm');

C++ 服务实现

grpc::Status ReleaseModel(grpc::ServerContext*, const ReleaseRequest* req, ReleaseResponse*) {
    SharedMemoryCache::instance().clear(req->shm_name());
    return grpc::Status::OK;
}

✅ 优点:释放时机精确,资源回收及时,无浪费。

C++ 服务内部自动触发

场景 A:加载新模型时自动替换
LoadModel 接口中,如果已有旧共享内存,先 clear() 再创建新的。

if (!current_shm_name.empty()) {
    SharedMemoryCache::instance().clear(current_shm_name);
}
// 然后创建新共享内存...

✅ 优点:无需额外 API,适合单模型应用(一次只打开一个点云)。

场景 B:LRU 缓存淘汰
当服务需要同时缓存多个点云时,可设置容量上限,超过后自动清理最久未使用的。

void evictIfNeeded() {
    if (cache_.size() > MAX_CACHE_COUNT) {
        auto oldest = cache_.begin();
        oldest->second->clear();
        cache_.erase(oldest);
    }
}

✅ 优点:多文档应用(如历史记录)下自动管理内存。

进程退出时自动清理(兜底机制)

在 C++ 服务的析构函数或 atexit 中遍历所有共享内存并调用 clear()

SharedMemoryCache::~SharedMemoryCache() {
    for (auto& entry : all_caches_) {
        entry.clear();
    }
}

✅ 优点:即使 Electron 忘记调用 Release,正常退出时也能清理。
⚠️ 注意:进程被 kill -9 强制终止时不会执行,但操作系统最终会回收所有内存。


推荐组合策略

在实际项目中,建议采用混合触发,兼顾灵活性与安全性:

触发方式 使用场景 作用
Electron 主动调用 Release 用户明确关闭模型、切换文件 主力释放机制,及时回收
加载新模型时自动替换 单模型应用(每次只加载一个点云) 防止旧数据遗留
进程退出时清理 任何情况 兜底,确保无泄漏

典型调用链路

用户点击“关闭点云” 
  → React 调用 window.electronAPI.closeModel()
  → Electron 主进程通过 gRPC 调用 C++ 服务的 ReleaseModel
  → C++ 服务端执行 SharedMemoryCache::clear(shm_name)
      → munmap → close → shm_unlink
  → 共享内存被彻底销毁

通过清晰的释放操作和完善的触发机制,gRPC + mmap 方案既能提供高性能零拷贝数据共享,又能保证内存资源被安全、及时地回收。


七、总结与展望

在 Electron + Three.js 的大点云渲染场景中,将计算下沉到 C++,并实现零拷贝数据共享是性能突破的关键。本文提供的两种方案各有千秋:

  • N-API 同进程方案:适合 1000 万点以下,追求极致简单和低延迟的项目。
  • gRPC+mmap 跨进程方案:适合超大规模、要求进程隔离、支持后台队列的专业级应用。

未来,随着 WebGPU 的成熟和 SharedArrayBuffer 的普及,我们甚至可以直接在 C++ 中操作 GPU 缓冲区,进一步降低 CPU 开销。但就目前而言,这套混合架构已经能在普通消费级电脑上流畅渲染 5000 万点云。

如果你正在开发类似的桌面三维工具,希望这篇文章能帮你少走弯路。欢迎在评论区交流你的实践心得!


参考资料


如果你觉得这篇文章有帮助,欢迎点赞收藏👍 有问题可以在评论区交流!


作者:红波 | 专注智驾、机器人标注工具与可视化开发 | 技术栈:TS/Vue/WebGPU/WebGL/ThreeJS/Go/Rust

基于 OPFS 的前端缓存实践:图片与点云数据的本地持久化

前言

在现代 Web 应用中,处理大量图片和三维点云数据时,重复的网络请求会严重影响加载速度和用户体验。浏览器提供的 Origin Private File System (OPFS) 为我们带来了新的解决方案——它允许 Web 应用在用户设备上读写专属于自己的文件系统,且完全隔离于其他源,无需用户授权即可使用。

本文将分享如何利用 OPFS 封装一个缓存管理器,用于缓存图片和点云几何数据。代码基于 TypeScript 编写,适用于需要在浏览器中高效复用资源的场景。

什么是 OPFS?

OPFS 是 File System Access API 的一部分,它为 Web 应用提供了一个私有的、与源绑定的文件系统。与传统的 IndexedDB 或 localStorage 相比,OPFS 支持高性能的文件读写操作,尤其适合存储二进制大对象。它的主要特点包括:

  • 完全隔离:每个源拥有独立的文件系统,互不干扰。
  • 无需用户授权:无需弹出权限请求。
  • 同步访问(在 Worker 中):支持同步 API,可大幅提升性能。
  • 持久化存储:数据会一直保留,除非用户手动清除。

设计目标

我们需要一个缓存系统,能够:

  1. 缓存从网络加载的图片(Blob 格式)。
  2. 缓存点云几何数据,包括位置、法线、颜色、强度、标签等属性(以 TypedArray 形式存储)。
  3. 支持基于 URL 的缓存键,确保同一资源只存一份。
  4. 提供简单的读写接口,屏蔽 OPFS 的复杂操作。

整体架构

缓存目录结构如下:

label-flow-cache/
├── images/
│   ├── <key>.bin          # 图片二进制数据
│   └── <key>.meta.json    # 图片元信息(如 MIME 类型)
└── pointClouds/
    ├── <key>/             # 每个点云数据一个子目录
    │   ├── meta.json      # 点云元信息(包含哪些属性、范围等)
    │   ├── position.bin   # 位置数组(Float32Array)
    │   ├── normal.bin     # 法线数组(可选)
    │   ├── color.bin      # 颜色数组(可选)
    │   ├── intensity.bin  # 强度数组(可选)
    │   └── label.bin      # 标签数组(可选)

缓存键的生成策略:从 URL 中提取 pathname + search + hash,然后计算 SHA-256 哈希作为最终键名。这样可以保证键名长度固定且唯一。

核心代码解析

1. 单例模式

export class OPFSCache {
  private static instance: OPFSCache;
  private constructor() {}

  public static getInstance(): OPFSCache {
    if (!OPFSCache.instance) {
      OPFSCache.instance = new OPFSCache();
    }
    return OPFSCache.instance;
  }
}

确保全局只有一个缓存实例,避免重复初始化。

2. 目录初始化

private async ensureCacheRootDir(): Promise<FileSystemDirectoryHandle | null> {
  if (typeof window === 'undefined') return null;
  const root = await getOPFSRoot(); // 外部提供的获取 OPFS 根句柄的函数
  if (!root) return null;
  try {
    return await root.getDirectoryHandle('label-flow-cache', { create: true });
  } catch {
    return null;
  }
}

递归获取或创建 label-flow-cache 目录,并缓存其句柄。imagespointClouds 子目录类似。

3. 缓存键生成

private async getFileKey(url: string): Promise<string> {
  const rawKey = getOPFScacheKey(url); // 提取 pathname + search + hash
  const hash = await this.sha256Hex(rawKey);
  if (hash) return hash;
  return encodeURIComponent(rawKey).replace(/%/g, '_').slice(0, 120);
}

优先使用 SHA-256 哈希作为文件名,如果浏览器不支持,则降级为编码后的原始键(截取前 120 个字符)。

4. 图片缓存

写入

public async setImage(url: string, blob: Blob): Promise<void> {
  const imagesDir = await this.ensureImagesDir();
  if (!imagesDir) return;
  const key = await this.getFileKey(url);
  await Promise.all([
    this.writeBlobFile(imagesDir, `${key}.bin`, blob),
    this.writeTextFile(imagesDir, `${key}.meta.json`, JSON.stringify({ type: blob.type }))
  ]);
}

将图片二进制数据和 MIME 类型分别存储。

读取

public async getImage(url: string): Promise<Blob | null> {
  const imagesDir = await this.ensureImagesDir();
  if (!imagesDir) return null;
  const key = await this.getFileKey(url);
  const metaText = await this.readTextFile(imagesDir, `${key}.meta.json`);
  const meta = metaText ? JSON.parse(metaText) : null;
  return await this.readFileBlob(imagesDir, `${key}.bin`, meta?.type);
}

读取元数据获取类型,然后读取二进制文件返回 Blob。

5. 点云缓存

数据结构定义

export interface PointCloudGeometryData {
  position?: Float32Array;
  normal?: Float32Array;
  color?: Float32Array;
  intensity?: Float32Array;
  label?: Int32Array;
  boundingSphere?: { center: [number, number, number]; radius: number };
  heightRange?: { min: number; max: number };
  intensityRange?: { min: number; max: number };
}

写入

public async setPointCloudGeometry(url: string, geometryData: PointCloudGeometryData): Promise<void> {
  const pointCloudsDir = await this.ensurePointCloudsDir();
  if (!pointCloudsDir) return;
  const key = await this.getFileKey(url);
  // 先删除旧目录(如果有)
  await pointCloudsDir.removeEntry(key, { recursive: true }).catch(() => {});
  const pcDir = await pointCloudsDir.getDirectoryHandle(key, { create: true });

  const meta: PointCloudMeta = {
    has: {
      position: !!geometryData.position,
      normal: !!geometryData.normal,
      color: !!geometryData.color,
      intensity: !!geometryData.intensity,
      label: !!geometryData.label,
    },
    boundingSphere: geometryData.boundingSphere,
    heightRange: geometryData.heightRange,
    intensityRange: geometryData.intensityRange,
  };

  const tasks: Promise<void>[] = [this.writeTextFile(pcDir, 'meta.json', JSON.stringify(meta))];

  if (geometryData.position) {
    tasks.push(this.writeBufferFile(pcDir, 'position.bin', this.copyViewToArrayBuffer(geometryData.position)));
  }
  // ... 其他属性类似

  await Promise.all(tasks);
}

为每个点云数据创建一个子目录,将元信息和各个属性分别存储为独立文件。注意写入前会删除旧目录,保证数据一致性。

读取

public async getPointCloudGeometry(url: string): Promise<PointCloudGeometryData | null> {
  const pointCloudsDir = await this.ensurePointCloudsDir();
  if (!pointCloudsDir) return null;
  const key = await this.getFileKey(url);
  let pcDir: FileSystemDirectoryHandle;
  try {
    pcDir = await pointCloudsDir.getDirectoryHandle(key);
  } catch {
    return null;
  }

  const metaText = await this.readTextFile(pcDir, 'meta.json');
  if (!metaText) return null;
  const meta = JSON.parse(metaText) as PointCloudMeta;

  const geometryData: PointCloudGeometryData = {
    boundingSphere: meta.boundingSphere,
    heightRange: meta.heightRange,
    intensityRange: meta.intensityRange,
  };

  if (meta.has.position) {
    const buf = await this.readFileBuffer(pcDir, 'position.bin');
    if (!buf) return null;
    geometryData.position = new Float32Array(buf);
  }
  // ... 其他属性类似

  return geometryData;
}

根据元信息动态读取对应文件,还原成 TypedArray。

6. 辅助方法

  • copyViewToArrayBuffer:将 TypedArray 的数据复制到一个新的 ArrayBuffer,避免共享底层内存带来的潜在问题。
  • writeBlobFile / writeTextFile / writeBufferFile:封装 OPFS 的写入操作。
  • readFileBlob / readTextFile / readFileBuffer:封装 OPFS 的读取操作。

完整代码

以下是经过适当脱敏(例如将示例中的 URL 处理函数替换为占位符)的完整代码。

export async function getOPFSRoot(): Promise<FileSystemDirectoryHandle | null> {
    const storage: any = navigator.storage
    if (!storage?.getDirectory) return null
    try {
        return (await storage.getDirectory()) as FileSystemDirectoryHandle
    } catch {
        return null
    }
}


export interface PointCloudGeometryData {
  position?: Float32Array;
  normal?: Float32Array;
  color?: Float32Array;
  intensity?: Float32Array;
  label?: Int32Array;
  boundingSphere?: {
    center: [number, number, number];
    radius: number;
  };
  heightRange?: {
    min: number;
    max: number;
  };
  intensityRange?: {
    min: number;
    max: number;
  };
}

export function getOPFScacheKey(src: string) {
  try {
    const url = new URL(src);
    return `${url.pathname}${url.search}${url.hash}`;
  } catch {
    return `${src}`;
  }
}

type ImageMeta = {
  type?: string;
};

type PointCloudMeta = {
  has: {
    position?: boolean;
    normal?: boolean;
    color?: boolean;
    intensity?: boolean;
    label?: boolean;
  };
  boundingSphere?: PointCloudGeometryData['boundingSphere'];
  heightRange?: PointCloudGeometryData['heightRange'];
  intensityRange?: PointCloudGeometryData['intensityRange'];
};

export class OPFSCache {
  private static instance: OPFSCache;

  private constructor() {}

  public static getInstance(): OPFSCache {
    if (!OPFSCache.instance) {
      OPFSCache.instance = new OPFSCache();
    }
    return OPFSCache.instance;
  }

  public async init(): Promise<void> {
    await this.ensureCacheRootDir();
  }

  private async ensureCacheRootDir(): Promise<FileSystemDirectoryHandle | null> {
    if (typeof window === 'undefined') return null;
    const root = await getOPFSRoot();
    if (!root) return null;
    try {
      return await root.getDirectoryHandle('label-flow-cache', { create: true });
    } catch {
      return null;
    }
  }

  private async ensureImagesDir(): Promise<FileSystemDirectoryHandle | null> {
    const root = await this.ensureCacheRootDir();
    if (!root) return null;
    try {
      return await root.getDirectoryHandle('images', { create: true });
    } catch {
      return null;
    }
  }

  private async ensurePointCloudsDir(): Promise<FileSystemDirectoryHandle | null> {
    const root = await this.ensureCacheRootDir();
    if (!root) return null;
    try {
      return await root.getDirectoryHandle('pointClouds', { create: true });
    } catch {
      return null;
    }
  }

  private async sha256Hex(input: string): Promise<string | null> {
    const subtle = globalThis.crypto?.subtle;
    if (!subtle) return null;
    try {
      const data = new TextEncoder().encode(input);
      const digest = await subtle.digest('SHA-256', data);
      return Array.from(new Uint8Array(digest))
        .map((b) => b.toString(16).padStart(2, '0'))
        .join('');
    } catch {
      return null;
    }
  }

  private async getFileKey(url: string): Promise<string> {
    const rawKey = getOPFScacheKey(url);
    const hash = await this.sha256Hex(rawKey);
    if (hash) return hash;
    return encodeURIComponent(rawKey).replace(/%/g, '_').slice(0, 120);
  }

  private async tryGetFileHandle(
    dir: FileSystemDirectoryHandle,
    name: string
  ): Promise<FileSystemFileHandle | null> {
    try {
      return await dir.getFileHandle(name);
    } catch {
      return null;
    }
  }

  private async writeBlobFile(
    dir: FileSystemDirectoryHandle,
    name: string,
    blob: Blob
  ): Promise<void> {
    const handle = await dir.getFileHandle(name, { create: true });
    const writable = await handle.createWritable();
    await writable.write(blob);
    await writable.close();
  }

  private async writeTextFile(
    dir: FileSystemDirectoryHandle,
    name: string,
    text: string
  ): Promise<void> {
    const handle = await dir.getFileHandle(name, { create: true });
    const writable = await handle.createWritable();
    await writable.write(text);
    await writable.close();
  }

  private async writeBufferFile(
    dir: FileSystemDirectoryHandle,
    name: string,
    buffer: ArrayBuffer
  ): Promise<void> {
    const handle = await dir.getFileHandle(name, { create: true });
    const writable = await handle.createWritable();
    await writable.write(buffer);
    await writable.close();
  }

  private copyViewToArrayBuffer(view: ArrayBufferView): ArrayBuffer {
    const arrayBuffer = new ArrayBuffer(view.byteLength);
    new Uint8Array(arrayBuffer).set(
      new Uint8Array(view.buffer, view.byteOffset, view.byteLength)
    );
    return arrayBuffer;
  }

  private async readTextFile(
    dir: FileSystemDirectoryHandle,
    name: string
  ): Promise<string | null> {
    const handle = await this.tryGetFileHandle(dir, name);
    if (!handle) return null;
    try {
      const file = await handle.getFile();
      return await file.text();
    } catch {
      return null;
    }
  }

  private async readFileBlob(
    dir: FileSystemDirectoryHandle,
    name: string,
    type?: string
  ): Promise<Blob | null> {
    const handle = await this.tryGetFileHandle(dir, name);
    if (!handle) return null;
    try {
      const file = await handle.getFile();
      const blobType = type || file.type;
      return file.slice(0, file.size, blobType);
    } catch {
      return null;
    }
  }

  private async readFileBuffer(
    dir: FileSystemDirectoryHandle,
    name: string
  ): Promise<ArrayBuffer | null> {
    const handle = await this.tryGetFileHandle(dir, name);
    if (!handle) return null;
    try {
      const file = await handle.getFile();
      return await file.arrayBuffer();
    } catch {
      return null;
    }
  }

  public async getImage(url: string): Promise<Blob | null> {
    try {
      const imagesDir = await this.ensureImagesDir();
      if (!imagesDir) return null;

      const key = await this.getFileKey(url);

      const metaText = await this.readTextFile(imagesDir, `${key}.meta.json`);
      const meta: ImageMeta | null = metaText ? JSON.parse(metaText) : null;

      return await this.readFileBlob(imagesDir, `${key}.bin`, meta?.type);
    } catch (error) {
      console.warn('读取图片缓存失败:', error);
      return null;
    }
  }

  public async setImage(url: string, blob: Blob): Promise<void> {
    try {
      const imagesDir = await this.ensureImagesDir();
      if (!imagesDir) return;

      const key = await this.getFileKey(url);
      await Promise.all([
        this.writeBlobFile(imagesDir, `${key}.bin`, blob),
        this.writeTextFile(
          imagesDir,
          `${key}.meta.json`,
          JSON.stringify({ type: blob.type } satisfies ImageMeta)
        ),
      ]);
    } catch (error) {
      console.warn('写入图片缓存失败:', error);
    }
  }

  public async getPointCloudGeometry(url: string): Promise<PointCloudGeometryData | null> {
    try {
      const pointCloudsDir = await this.ensurePointCloudsDir();
      if (!pointCloudsDir) return null;

      const key = await this.getFileKey(url);
      let pcDir: FileSystemDirectoryHandle;
      try {
        pcDir = await pointCloudsDir.getDirectoryHandle(key);
      } catch {
        return null;
      }

      const metaText = await this.readTextFile(pcDir, 'meta.json');
      if (!metaText) return null;
      const meta = JSON.parse(metaText) as PointCloudMeta;

      const geometryData: PointCloudGeometryData = {
        boundingSphere: meta.boundingSphere,
        heightRange: meta.heightRange,
        intensityRange: meta.intensityRange,
      };

      if (meta.has.position) {
        const buf = await this.readFileBuffer(pcDir, 'position.bin');
        if (!buf) return null;
        geometryData.position = new Float32Array(buf);
      }

      if (meta.has.normal) {
        const buf = await this.readFileBuffer(pcDir, 'normal.bin');
        if (!buf) return null;
        geometryData.normal = new Float32Array(buf);
      }

      if (meta.has.color) {
        const buf = await this.readFileBuffer(pcDir, 'color.bin');
        if (!buf) return null;
        geometryData.color = new Float32Array(buf);
      }

      if (meta.has.intensity) {
        const buf = await this.readFileBuffer(pcDir, 'intensity.bin');
        if (!buf) return null;
        geometryData.intensity = new Float32Array(buf);
      }

      if (meta.has.label) {
        const buf = await this.readFileBuffer(pcDir, 'label.bin');
        if (!buf) return null;
        geometryData.label = new Int32Array(buf);
      }

      return geometryData;
    } catch (error) {
      console.warn('读取点云缓存失败:', error);
      return null;
    }
  }

  public async setPointCloudGeometry(
    url: string,
    geometryData: PointCloudGeometryData
  ): Promise<void> {
    try {
      const pointCloudsDir = await this.ensurePointCloudsDir();
      if (!pointCloudsDir) return;

      const key = await this.getFileKey(url);
      await pointCloudsDir.removeEntry(key, { recursive: true }).catch(() => {});
      const pcDir = await pointCloudsDir.getDirectoryHandle(key, { create: true });

      const meta: PointCloudMeta = {
        has: {
          position: !!geometryData.position,
          normal: !!geometryData.normal,
          color: !!geometryData.color,
          intensity: !!geometryData.intensity,
          label: !!geometryData.label,
        },
        boundingSphere: geometryData.boundingSphere,
        heightRange: geometryData.heightRange,
        intensityRange: geometryData.intensityRange,
      };

      const tasks: Promise<void>[] = [
        this.writeTextFile(pcDir, 'meta.json', JSON.stringify(meta)),
      ];

      if (geometryData.position) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'position.bin',
            this.copyViewToArrayBuffer(geometryData.position)
          )
        );
      }

      if (geometryData.normal) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'normal.bin',
            this.copyViewToArrayBuffer(geometryData.normal)
          )
        );
      }

      if (geometryData.color) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'color.bin',
            this.copyViewToArrayBuffer(geometryData.color)
          )
        );
      }

      if (geometryData.intensity) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'intensity.bin',
            this.copyViewToArrayBuffer(geometryData.intensity)
          )
        );
      }

      if (geometryData.label) {
        tasks.push(
          this.writeBufferFile(
            pcDir,
            'label.bin',
            this.copyViewToArrayBuffer(geometryData.label)
          )
        );
      }

      await Promise.all(tasks);
    } catch (error) {
      console.warn('写入点云缓存失败:', error);
    }
  }
}

export const opfsCache = OPFSCache.getInstance();

使用示例

// 初始化(建议在应用启动时调用)
await opfsCache.init();

// 缓存图片
const response = await fetch('https://example.com/image.jpg');
const blob = await response.blob();
await opfsCache.setImage('https://example.com/image.jpg', blob);

// 获取图片
const cachedBlob = await opfsCache.getImage('https://example.com/image.jpg');

// 缓存点云数据
const geometry = {
  position: new Float32Array([...]),
  color: new Float32Array([...]),
  // ...
};
await opfsCache.setPointCloudGeometry('https://example.com/cloud.pcd', geometry);

// 获取点云数据
const cachedGeometry = await opfsCache.getPointCloudGeometry('https://example.com/cloud.pcd');

总结与注意事项

  • 性能优势:OPFS 提供了接近本地文件系统的读写速度,远优于 IndexedDB 的随机访问性能。
  • 存储容量:OPFS 的存储限制通常与浏览器分配给网站的总存储空间一致(一般较大),但具体取决于浏览器实现。
  • 兼容性:OPFS 在现代浏览器(Chrome 86+、Edge 86+、Safari 15.2+)中得到广泛支持,但在旧版本浏览器中需要降级方案。
  • 数据清理:由于数据存储在用户的私密空间中,开发者无需担心隐私问题。但需要注意及时清理无用缓存,避免占用过多磁盘空间。
  • 错误处理:代码中已经添加了 try-catch,保证了缓存操作失败时不会影响主业务流程。

通过 OPFS,我们可以轻松实现前端高性能缓存,为图片密集型和点云应用带来质的飞跃。希望本文能为大家提供一些实用的思路和代码参考。

❌