基于利用Draco对点云数据进行编码解码以实现高效网络传输一文介绍个人项目中关于three.jsDraco的使用实践。

threejs渲染

three.js的官方说明中对于对于点云数据的加载主要是通过PCDLoader,其官方的使用说明如下,十分简洁。

// instantiate a loader
const loader = new PCDLoader();

// load a resource
loader.load(
    // resource URL
    'pointcloud.pcd',
    // called when the resource is loaded
    function(points) {
        scene.add(points);
    },
    // called when loading is in progress
    function(xhr) {
        console.log((xhr.loaded / xhr.total * 100) + '% loaded');
    },

    // called when loading has errors
    function(error) {
        console.log('An error happened');
    }
);

上述API是通过直接加载pcd点云文件的方式实现的,而我们常规的使用场景是pcd文件在服务器端,需要在客户端读取并渲染,涉及到字节流的传输,此时客户端不存在现成的pcd文件,需要接收字节形式的点云数据。

基于网络传输点云文件

上述基于PCDLoader实现的代码虽然很简洁,但其只能读取文件或URL数据流,使用范围受到一定程度的约束,个人希望在项目中能直接基于点云数据集合进行渲染。

参考网络上的相关资料,修改后的代码如下,其通过renderPcd()函数接收点云数据集合并渲染,适用性更广。

import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';

export const initComponments = (width, height, parent) => {
    let renderer = new THREE.WebGLRenderer({ antialias: true });
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(width, height);
    parent.appendChild(renderer.domElement);

    let scene = new THREE.Scene();

    let camera = new THREE.PerspectiveCamera(12, width / height, 0.5, 50000);
    camera.position.z = 310;
    scene.add(camera);

    let controls = new OrbitControls(camera, renderer.domElement);
    controls.addEventListener('change', () => renderer.render(scene, camera)); // use if there is no animation loop
    controls.target = new THREE.Vector3(0, 0, 1);
    controls.autoRotate = false;
    controls.dampingFactor = 0.25;

    // 控制缩放范围
    //controls.minDistance = 0.1;
    //controls.maxDistance = 100;

    //scene.add( new THREE.AxesHelper( 1 ) );

    window.addEventListener('resize', () => {
        camera.aspect = width / height;
        camera.updateProjectionMatrix();
        renderer.setSize(width, height);
        renderer.render(scene, camera);
    });
    return [renderer, camera, scene];
}

export const renderPcd = (name, data, renderer, camera, scene) => {
    // 移除旧点云数据
    for (let child of scene.children) {
        if (child.type === 'Points') {
            scene.remove(child);
            break;
        }
    }

    let geometry = new THREE.BufferGeometry();
    let material = new THREE.PointsMaterial({ size: 0.05, vertexColors: 2 });  //vertexColors: THREE.VertexColors
    let points = new THREE.Points(geometry, material);
    let positions = Float32Array.from(data);

    let color = []
    for (let i = 0; i < data.length; i += 3) {
        color[i] = 0.12;
        color[i + 1] = 0.565;
        color[i + 2] = 1;
    }
    let colors = new Float32Array(color)

    geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
    geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
    geometry.center();
    geometry.rotateX(Math.PI);

    // 沿y轴方向平移一定单位
    //points.translateY(10);
    //points.translateX(70);
    points.name = name;

    // 图像缩放
    points.scale.set(1.2, 1.2, 1.2);
    scene.add(points);
    renderer.render(scene, camera);
}
import { PcdData } from '../proto/pcd_pb';
import { request } from '../tools/axios_tools';
import { initComponments, renderPcd } from '../tools/play_tools';

let renderer, camera, scene;
export const clearPcdData = () => {
    renderer = null;
    camera = null;
    scene = null;
}

export const playPcd = (pId, pHeight, data, playingRef, updateState) => {
    // 进行一些必要的初始化操作
    let parent = document.getElementById(`${pId}`);
    let width = parent.offsetWidth;

    let total = data.total;
    if (total === 0) {
        return;
    }
    if (!scene) {
        let [sRenderer, sCamera, sScene] = initComponments(width, pHeight, parent);
        renderer = sRenderer;
        camera = sCamera;
        scene = sScene;
    }
    loadPlayPcd(data, playingRef, updateState, renderer, camera, scene);
};

let count = 0;
export const loadPlayPcd = (data, playingRef, updateState, renderer, camera, scene) => {
    let file = data.file[count];
    let total = data.total;
    count++;
    let url = `main/loadPcdBinary?pcd=${file}`;
    request.get(url, { responseType: "arraybuffer" }).then(function (response) {
        if (!playingRef.current) {
            return;
        }
        let result = PcdData.deserializeBinary(response.data);
        renderPcd(result.getName(), result.getPointList(), renderer, camera, scene);
        if (count < total) {
            let percent = (count / total * 100).toFixed(2);
            updateState({ 'progress': percent, processCount: count });
            loadPlayPcd(data, playingRef, updateState, renderer, camera, scene)
        } else {
            updateState({ 'progress': 0, processCount: 0 });
            count = 0;
        }
    }).catch(function (error) {
        console.log(error);
    });
};

服务端实现

服务端采用Express实现,其主要功能如下:

  1. JSON格式显示pcdplydrc等点云文件的整体信息
  2. pcd文件转化为plydrc格式的点云文件,相关操作均基于JavaScript实现
  3. Protocol Buffers编码的形式提供pcdplydrc格式点云文件的下载,供客户端下载并渲染

相关代码位于draco-point-cloud-server,可将其下载到本地直接运行,也可基于下述相关指令构建Docker镜像并运行,或采用个人已经提前构建好的镜像draco_server

# build image
docker build -t lucumt/draco_server:1.0 .

# run docker
docker run -d -p 8000:8000 --name draco_server lucumt/draco_server:1.0

# stop docker
docker stop draco_server && docker rm draco_server
FROM node:22.14.0-alpine

RUN mkdir /draco

COPY draco-point-cloud-server.zip /draco/draco-point-cloud-server.zip
WORKDIR /draco

RUN unzip draco-point-cloud-server.zip && rm draco-point-cloud-server.zip
RUN mv draco-point-cloud-server server

WORKDIR /draco/server
RUN npm i

ENV PORT=8000
CMD ["node", "./bin/www"]

启动该服务后在浏览器中打开,其主页面显示如下

Draco服务端运行界面

点击相关链接即可进行相关操作,而基于JavaScript通过Dracopcd转化为drc文件十分耗时,故推荐采用脚本进行批量转化以加快转化时间,具体请参见脚本转换

为了方便使用,个人提前在该项目中已经准备好了若干pcddrc格式的点云数据,通过源码或镜像启动服务端后可直接让客户端下载相关数据并进行渲染。

Draco测试文件列表

客户端实现

客户端基于React实现,其主要功能如下:

  1. 获取服务端pcddrc文件的总数信息,并在页面展示
  2. 下载pcddrc文件,基于Protocol Buffers进行解码,并对drc文件通过Draco进行进一步的解码
  3. 针对pcddrc文件,分别基于threejs进行点云渲染,可进行开始播放、暂停播放、切换播放源等操作

相关的代码位于draco-point-cloud-client,可将其下载到本地自行编译部署,也可基于下述相关指令构建Docker镜像并运行,或采用个人已经提前构建好的镜像draco_client

# build image
docker build -t lucumt/draco_client:1.0 .

# run docker
docker run -d -p 3000:3000 --name draco_client lucumt/draco_client:1.0

# stop docker
docker stop draco_client && docker rm draco_client
FROM nginx

COPY dist /usr/share/nginx/html
COPY draco.conf /etc/nginx/conf.d/

EXPOSE 3000
server {
    listen       3000;
    server_name  draco-server;

    add_header Access-Control-Allow-Origin *;
    add_header Access-Control-Allow-Methods 'GET, POST,PUT,DELETE, OPTIONS';
    add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';


    location / {
      root   /usr/share/nginx/html;
      index  index.html index.htm;
    }

    error_page   500 502 503 504  /50x.html;
    location = /50x.html {
        root   /usr/share/nginx/html;
    }
}

启动该服务后在浏览器中打开,其主页面显示如下

Draco客户端界面

点击图中的播放按钮,会加载点云文件并基于threejs进行渲染展示,或者通过顶部的tab菜单切换不同的数据格式播放

Draco客户端点云渲染

相关错误修复

Draco启动报错

基于Draco官方的demo说明,在客户端项目中添加如下代码进行Draco初始化

import draco3d from 'draco3d';

draco3d.createDecoderModule({}).then(function(module) {
    // xxx
});

通过npm start启动会发现其立即报错,报错信息类似如下

Draco引入后启动报错

此问题的根源是自己使用的react-scripts版本为5.0.1导致的,通过将其版本降低到4.0.3可修复,但自己并不想降低其版本,于是参考这篇文章中说的第2种方法来解决:

1.先确保项目中没有要提交的代码,git status显示无任何修改的内容,然后执行下述指令将React项目的webpack config从node_modules里暴露出来

npm run eject

2.安装对应依赖包

npm i stream-browserify buffer path-browserify crypto-browserify crypto-browserify

3.测试根据报错提示在config/webpack.config.js下搜索resolve:,并添加如下内容

fallback: {
  "stream": require.resolve("stream-browserify"),
  "buffer": require.resolve("buffer/"),
  "path": require.resolve("path-browserify"),
  "crypto": require.resolve("crypto-browserify")
}

4.之后重新执行npm start即可正常启动。

Draco播放报错

修复完上述问题后,点击"Draco播放"以基于drc文件播放时,页面上会提示如下错误

Draco播放时页面报错

查看浏览器控制台有类似如下报错

Draco播放时控制台报错

基于报错信息去网络查找,大部分都说要添加draco_decoder.wasm文件,继续在控制台查看网络请求,发现其确实在请求该文件,且加载报错

Draco加载wasm文件报错

进一步查看其请求路径如下,可知其是在static/js目录下查找该文件,其对应于项目中的public/static/js目录,检查后发现确实没有该文件,将draco_decoder.wasm放入该目录 即可解决此问题。

Draco加载wasm文件请求

效果展示对比

本章节主要利用前述预先准备好的120个pcddrc文件,在客户端对它们分别进行连续播放测试,对比其耗时以及显示效果。

这篇文章中已经说明了利用Draco压缩为drc文件后可大幅减少点云文件的大小

点云文件大小对比

分别基于pcddrc进行播放,其耗时对比如下

pcd文件加载耗时

drc文件加载耗时

从上述对比图可看出drc文件相对于原始的pcd文件,即使在经过Protocol Buffers序列化,其大小是原文件的1/20,且耗时在10ms级别,基本上实现了无感知延迟的点云连续播放。

基于公司内部局域网,将前述的120帧点云文件全部播放完毕后对比效果如下:

pcd格式的点云文件全部播放完毕耗时58s,有肉眼可见的明显延迟

pcd连续播放效果

drc格式的点云文件全部播放完毕耗时7s,播放体验十分流畅,基本满足使用需求。

drc连续播放效果