Three.js中PCDLoader.js加载点云原理分析

1.源码

既然想知道原理,第一步必须是翻看PCDLoader.js的源码!

2.源码分析

1.代码结构

代码经过精简,大概是下面这么个结构。

js 复制代码
// 导入需要使用的 THREE.js 类和方法
import {
    BufferGeometry,
    Color,
    FileLoader,
    Float32BufferAttribute,
    Int32BufferAttribute,
    Loader,
    Points,
    PointsMaterial
} from 'three';

// PCDLoader 类继承了 THREE.js 的 Loader 类,
// 用于加载 PCD (Point Cloud Data) 格式的文件。
class PCDLoader extends Loader {

    // 构造函数,用于创建 PCDLoader 类的实例对象
    constructor(manager) { }

    // 加载方法
    load(url, onLoad, onProgress, onError) { }
    // 解析方法,解析 PCD 格式数据
    parse(data) {

        // 解压数据
        function decompressLZF(inData, outLength) { }

        // 解析头信息
        function parseHeader(data) { }

        // 解析点信息
        // ...
    }
}

// 导出 PCDLoader 类
export { PCDLoader };

2.代码具体分析

调用路径:new PCDLoader() => loader.load => 源码里的load方法 =>onLoad(scope.parse(data)) => parse() => parseHeader() => 判断解析类型为 ASCII 的数据代码块 =>判断解析类型为 二进制压缩格式 的数据 => 上一步解析的时候调用了decompressLZF方法用于解压缩 => 判断解析类型为 二进制压缩格式 的数据 => 构建点云对象并返回代码块

当你使用PCDLoader.js加载pcd点云文件时:

js 复制代码
import { PCDLoader } from 'three/examples/jsm/loaders/PCDLoader.js'

// 初始化PCDLoader并加载.pcd文件
const loader = new PCDLoader()
loader.load(url, function (points) {
    scene.add(points)
})

上面的代码首先是执行 const loader = new PCDLoader() ,创建了 PCDLoader 类的实例对象,然后loader.load这个就到源码里的load方法了:

js 复制代码
    // 加载方法
    load(url, onLoad, onProgress, onError) {

        const scope = this;
        // 创建文件加载器对象
        const loader = new FileLoader(scope.manager);
        // 设置各种参数
        loader.setPath(scope.path);
        loader.setResponseType('arraybuffer');
        loader.setRequestHeader(scope.requestHeader);
        loader.setWithCredentials(scope.withCredentials);
        // 开始加载文件
        loader.load(url, function (data) {

            try {
                // 解析数据
                onLoad(scope.parse(data));
            } catch (e) {
                // 错误处理
                if (onError) {

                    onError(e);

                } else {

                    console.error(e);

                }

                scope.manager.itemError(url);

            }

        }, onProgress, onError);

    }

然后上面的 onLoad(scope.parse(data)) 这行就调用了 parse(),而 onLoad 是回调:

parse中先执行的是下面这段代码:

js 复制代码
        // 使用 TextDecoder 解码数据流,用于将二进制数据转换为文本格式。
        const textData = new TextDecoder().decode(data);
        // 解析文件头(Header),这通常是 ASCII 格式的文本,
        // 用于描述点云数据的一些基础信息
        const PCDheader = parseHeader(textData);
        // 初始化点云的位置(coordinates)数组
        const position = [];
        // 初始化点云的法线(normals)数组
        const normal = [];
        // 初始化点云的颜色(colors)数组
        const color = [];
        // 初始化点云的强度(intensity)数组
        const intensity = [];
        // 初始化点云的标签(labels)数组
        const label = [];
        // 初始化颜色对象
        const c = new Color();

上面的代码中我们注意到 parseHeader(textData) 这行代码是调用了 parseHeader 方法的:

js 复制代码
        // 定义 parseHeader 函数,接受点云数据(通常为文本)作为参数
        function parseHeader(data) {

            // 初始化一个空对象,用于存储解析后的头部信息
            const PCDheader = {};
            // 使用正则表达式找到"DATA"关键字出现的位置
            const result1 = data.search(/[\r\n]DATA\s(\S*)\s/i);
            // 从找到的位置开始,提取与"DATA"关键字关联的值
            const result2 = /[\r\n]DATA\s(\S*)\s/i.exec(data.slice(result1 - 1));
            // 存储与"DATA"关联的值
            PCDheader.data = result2[1];
            // 存储头部信息的总长度
            PCDheader.headerLen = result2[0].length + result1;
            // 截取并存储整个头部的字符串信息
            PCDheader.str = data.slice(0, PCDheader.headerLen);
            // 删除注释(以 '#' 开头的行)
            PCDheader.str = PCDheader.str.replace(/#.*/gi, '');
            // 使用正则表达式解析各种头部字段,并将它们存储在对象中
            // 解析版本、字段、大小、类型、数量、宽度、高度、视点和点数
            PCDheader.version = /VERSION (.*)/i.exec(PCDheader.str);
            PCDheader.fields = /FIELDS (.*)/i.exec(PCDheader.str);
            PCDheader.size = /SIZE (.*)/i.exec(PCDheader.str);
            PCDheader.type = /TYPE (.*)/i.exec(PCDheader.str);
            PCDheader.count = /COUNT (.*)/i.exec(PCDheader.str);
            PCDheader.width = /WIDTH (.*)/i.exec(PCDheader.str);
            PCDheader.height = /HEIGHT (.*)/i.exec(PCDheader.str);
            PCDheader.viewpoint = /VIEWPOINT (.*)/i.exec(PCDheader.str);
            PCDheader.points = /POINTS (.*)/i.exec(PCDheader.str);
            // 根据解析结果,进一步处理和存储字段值
            if (PCDheader.version !== null)
                PCDheader.version = parseFloat(PCDheader.version[1]);
            // 字段信息变成数组
            PCDheader.fields = (PCDheader.fields !== null) ? PCDheader.fields[1].split(' ') : [];
            // 类型信息变成数组
            if (PCDheader.type !== null)
                PCDheader.type = PCDheader.type[1].split(' ');
            // 宽度和高度转换为整数
            if (PCDheader.width !== null)
                PCDheader.width = parseInt(PCDheader.width[1]);
            if (PCDheader.height !== null)
                PCDheader.height = parseInt(PCDheader.height[1]);
            // 存储视点信息
            if (PCDheader.viewpoint !== null)
                PCDheader.viewpoint = PCDheader.viewpoint[1];
            // 点数转换为整数
            if (PCDheader.points !== null)
                PCDheader.points = parseInt(PCDheader.points[1], 10);
            // 如果点数是 null,则计算点数(宽度 * 高度) 
            if (PCDheader.points === null)
                PCDheader.points = PCDheader.width * PCDheader.height;
            // 处理"SIZE"和"COUNT"字段,将字符串信息转换为整数数组
            if (PCDheader.size !== null) {
                PCDheader.size = PCDheader.size[1].split(' ').map(function (x) {
                    return parseInt(x, 10);
                });
            }
            if (PCDheader.count !== null) {
                PCDheader.count = PCDheader.count[1].split(' ').map(function (x) {
                    return parseInt(x, 10);
                });
            } else {
                PCDheader.count = [];
                for (let i = 0, l = PCDheader.fields.length; i < l; i++) {
                    PCDheader.count.push(1);
                }
            }
            // 初始化一个偏移对象,用于存储每个字段在数据中的偏移位置
            PCDheader.offset = {};
            // 计算偏移和行大小(仅用于二进制数据)
            let sizeSum = 0;
            for (let i = 0, l = PCDheader.fields.length; i < l; i++) {
                if (PCDheader.data === 'ascii') {
                    PCDheader.offset[PCDheader.fields[i]] = i;
                } else {
                    PCDheader.offset[PCDheader.fields[i]] = sizeSum;
                    sizeSum += PCDheader.size[i] * PCDheader.count[i];
                }
            }
            // 仅用于二进制数据:存储一行数据的总字节数
            PCDheader.rowSize = sizeSum;
            // 返回解析后的头部信息对象
            return PCDheader;
        }

继续往下走解析类型为 ASCII 的数据

js 复制代码
        // 解析类型为 ASCII 的数据
        if (PCDheader.data === 'ascii') {
            // 获取字段偏移信息
            const offset = PCDheader.offset;
            // 去掉头部信息,只保留点云数据
            const pcdData = textData.slice(PCDheader.headerLen);
            // 按行切割数据
            const lines = pcdData.split('\n');
            // 遍历每一行数据
            for (let i = 0, l = lines.length; i < l; i++) {
                // 跳过空行
                if (lines[i] === '') continue;
                // 分割每一行的元素
                const line = lines[i].split(' ');
                // 如果存在 x, y, z 偏移,解析并存储位置信息
                if (offset.x !== undefined) {
                    position.push(parseFloat(line[offset.x]));
                    position.push(parseFloat(line[offset.y]));
                    position.push(parseFloat(line[offset.z]));
                }
                // 如果存在 RGB 偏移,解析并存储颜色信息
                if (offset.rgb !== undefined) {
                    // 查找 RGB 字段的类型
                    const rgb_field_index = PCDheader.fields.findIndex((field) => field === 'rgb');
                    const rgb_type = PCDheader.type[rgb_field_index];
                    // 解析 RGB 值
                    const float = parseFloat(line[offset.rgb]);
                    let rgb = float;
                    // 如果 RGB 类型为 'F'(浮点数),则将其转换为整数
                    if (rgb_type === 'F') {
                        const farr = new Float32Array(1);
                        farr[0] = float;
                        rgb = new Int32Array(farr.buffer)[0];
                    }
                    // 从 RGB 整数中提取 R, G, B 值,并转换为 [0,1] 范围
                    const r = ((rgb >> 16) & 0x0000ff) / 255;
                    const g = ((rgb >> 8) & 0x0000ff) / 255;
                    const b = ((rgb >> 0) & 0x0000ff) / 255;
                    // 转换颜色并存储
                    c.set(r, g, b).convertSRGBToLinear();
                    color.push(c.r, c.g, c.b);
                }
                // 如果存在法线信息,解析并存储
                if (offset.normal_x !== undefined) {
                    normal.push(parseFloat(line[offset.normal_x]));
                    normal.push(parseFloat(line[offset.normal_y]));
                    normal.push(parseFloat(line[offset.normal_z]));
                }
                // 如果存在光照强度信息,解析并存储
                if (offset.intensity !== undefined) {
                    intensity.push(parseFloat(line[offset.intensity]));
                }
                // 如果存在标签信息,解析并存储
                if (offset.label !== undefined) {
                    label.push(parseInt(line[offset.label]));
                }
            }
        }

继续往下走解析类型为 二进制压缩格式 的数据

js 复制代码
        // 通常 PCD 文件中的数据被组织为结构数组:XYZRGBXYZRGB
        // 二进制压缩的 PCD 文件将其数据组织为数组结构: XXYYZZRGBRGB
        // 与非压缩数据相比,这需要完全不同的解析方法
        
       // 解析类型为 二进制压缩格式 的数据
        if (PCDheader.data === 'binary_compressed') {
            // 读取压缩和解压缩大小
            const sizes = new Uint32Array(data.slice(PCDheader.headerLen, PCDheader.headerLen + 8));
            const compressedSize = sizes[0];
            const decompressedSize = sizes[1];
            // 进行LZF解压缩
            const decompressed = decompressLZF(new Uint8Array(data, PCDheader.headerLen + 8, compressedSize), decompressedSize);
            // 创建一个DataView对象以读取解压后的二进制数据
            const dataview = new DataView(decompressed.buffer);
            // 获取每个字段(比如x, y, z, rgb等)在数据中的偏移量
            const offset = PCDheader.offset;
            // 遍历所有点
            for (let i = 0; i < PCDheader.points; i++) {
                // 如果存在x字段
                if (offset.x !== undefined) {
                    // 寻找x, y, z字段在字段列表中的索引
                    const xIndex = PCDheader.fields.indexOf('x');
                    const yIndex = PCDheader.fields.indexOf('y');
                    const zIndex = PCDheader.fields.indexOf('z');
                    // 读取并存储x, y, z坐标
                    position.push(dataview.getFloat32((PCDheader.points * offset.x) + PCDheader.size[xIndex] * i, this.littleEndian));
                    position.push(dataview.getFloat32((PCDheader.points * offset.y) + PCDheader.size[yIndex] * i, this.littleEndian));
                    position.push(dataview.getFloat32((PCDheader.points * offset.z) + PCDheader.size[zIndex] * i, this.littleEndian));
                }
                // 如果存在rgb字段
                if (offset.rgb !== undefined) {
                    // 寻找rgb字段在字段列表中的索引
                    const rgbIndex = PCDheader.fields.indexOf('rgb');
                    // 读取并存储r, g, b颜色值
                    const r = dataview.getUint8((PCDheader.points * offset.rgb) + PCDheader.size[rgbIndex] * i + 2) / 255.0;
                    const g = dataview.getUint8((PCDheader.points * offset.rgb) + PCDheader.size[rgbIndex] * i + 1) / 255.0;
                    const b = dataview.getUint8((PCDheader.points * offset.rgb) + PCDheader.size[rgbIndex] * i + 0) / 255.0;
                    // 将sRGB颜色转换为线性颜色
                    c.set(r, g, b).convertSRGBToLinear();
                    // 存储颜色
                    color.push(c.r, c.g, c.b);
                }
                // 如果存在normal_x字段
                if (offset.normal_x !== undefined) {
                    // 寻找normal_x, normal_y, normal_z字段在字段列表中的索引
                    const xIndex = PCDheader.fields.indexOf('normal_x');
                    const yIndex = PCDheader.fields.indexOf('normal_y');
                    const zIndex = PCDheader.fields.indexOf('normal_z');
                    // 读取并存储法线信息
                    normal.push(dataview.getFloat32((PCDheader.points * offset.normal_x) + PCDheader.size[xIndex] * i, this.littleEndian));
                    normal.push(dataview.getFloat32((PCDheader.points * offset.normal_y) + PCDheader.size[yIndex] * i, this.littleEndian));
                    normal.push(dataview.getFloat32((PCDheader.points * offset.normal_z) + PCDheader.size[zIndex] * i, this.littleEndian));
                }
                // 如果存在intensity字段
                if (offset.intensity !== undefined) {
                    // 寻找intensity字段在字段列表中的索引
                    const intensityIndex = PCDheader.fields.indexOf('intensity');
                    // 读取并存储光强值
                    intensity.push(dataview.getFloat32((PCDheader.points * offset.intensity) + PCDheader.size[intensityIndex] * i, this.littleEndian));
                }
                // 如果存在label字段
                if (offset.label !== undefined) {
                    // 寻找label字段在字段列表中的索引
                    const labelIndex = PCDheader.fields.indexOf('label');
                    // 读取并存储标签值
                    label.push(dataview.getInt32((PCDheader.points * offset.label) + PCDheader.size[labelIndex] * i, this.littleEndian));
                }
            }
        } 

注意在上面的代码中调用了 decompressLZF 方法用于解压缩二进制压缩格式数据:

js 复制代码
        // 定义解压缩LZF的函数,接受压缩数据和解压缩后数据的长度
        function decompressLZF(inData, outLength) {
            // 获取输入数据的长度
            const inLength = inData.length;
            // 创建一个Uint8Array作为输出数据的缓冲区
            const outData = new Uint8Array(outLength);
            // 初始化输入和输出的指针
            let inPtr = 0;
            let outPtr = 0;
            // 初始化其他变量
            let ctrl;
            let len;
            let ref;
            // 主解压缩循环
            do {
                // 读取控制字节
                ctrl = inData[inPtr++];
                // 判断是否为字面量(非重复数据)
                if (ctrl < (1 << 5)) {
                    // 更新字面量长度
                    ctrl++;
                    // 检查输出缓冲区是否足够大
                    if (outPtr + ctrl > outLength) throw new Error('Output buffer is not large enough');
                    // 检查输入数据是否有效
                    if (inPtr + ctrl > inLength) throw new Error('Invalid compressed data');
                    // 复制字面量到输出缓冲区
                    do {
                        outData[outPtr++] = inData[inPtr++];
                    } while (--ctrl);
                } else { // 否则,处理重复数据
                    // 获取长度和引用偏移量
                    len = ctrl >> 5;
                    ref = outPtr - ((ctrl & 0x1f) << 8) - 1;
                    // 检查输入数据是否有效
                    if (inPtr >= inLength) throw new Error('Invalid compressed data');
                    // 检查长度是否需要一个额外的字节
                    if (len === 7) {
                        len += inData[inPtr++];
                        if (inPtr >= inLength) throw new Error('Invalid compressed data');
                    }
                    // 更新引用偏移量
                    ref -= inData[inPtr++];
                    // 检查输出缓冲区是否足够大
                    if (outPtr + len + 2 > outLength) throw new Error('Output buffer is not large enough');
                    // 检查引用偏移量是否有效
                    if (ref < 0) throw new Error('Invalid compressed data');
                    if (ref >= outPtr) throw new Error('Invalid compressed data');
                    // 从引用偏移量开始复制数据到输出缓冲区
                    do {
                        outData[outPtr++] = outData[ref++];
                    } while (--len + 2);
                }
            } while (inPtr < inLength); // 继续解压缩,直到输入数据被完全读取
            // 返回解压缩后的数据
            return outData;
        }

继续往下走解析类型为 二进制压缩格式 的数据

js 复制代码
        // 解析类型为 二进制格式 的数据
        if (PCDheader.data === 'binary') {
            // 创建一个DataView对象以便更容易地访问二进制数据
            const dataview = new DataView(data, PCDheader.headerLen);
            // 获取点云数据字段的偏移量
            const offset = PCDheader.offset;
            // 遍历所有点
            for (let i = 0, row = 0; i < PCDheader.points; i++, row += PCDheader.rowSize) {
                // 如果数据中包含x、y、z坐标
                if (offset.x !== undefined) {
                    // 读取并存储x、y、z坐标
                    position.push(dataview.getFloat32(row + offset.x, this.littleEndian));
                    position.push(dataview.getFloat32(row + offset.y, this.littleEndian));
                    position.push(dataview.getFloat32(row + offset.z, this.littleEndian));
                }
                // 如果数据中包含RGB颜色信息
                if (offset.rgb !== undefined) {
                    // 读取并存储RGB值,然后将其转换为线性空间
                    const r = dataview.getUint8(row + offset.rgb + 2) / 255.0;
                    const g = dataview.getUint8(row + offset.rgb + 1) / 255.0;
                    const b = dataview.getUint8(row + offset.rgb + 0) / 255.0;
                    c.set(r, g, b).convertSRGBToLinear();
                    color.push(c.r, c.g, c.b);
                }
                // 如果数据中包含法线信息
                if (offset.normal_x !== undefined) {
                    // 读取并存储法线向量
                    normal.push(dataview.getFloat32(row + offset.normal_x, this.littleEndian));
                    normal.push(dataview.getFloat32(row + offset.normal_y, this.littleEndian));
                    normal.push(dataview.getFloat32(row + offset.normal_z, this.littleEndian));
                }
                // 如果数据中包含光照强度信息
                if (offset.intensity !== undefined) {
                    // 读取并存储光照强度
                    intensity.push(dataview.getFloat32(row + offset.intensity, this.littleEndian));
                }
                // 如果数据中包含标签信息
                if (offset.label !== undefined) {
                    // 读取并存储标签
                    label.push(dataview.getInt32(row + offset.label, this.littleEndian));
                }
            }
        }

继续往下走最后一步构建点云对象并返回

js 复制代码
        // 构建几何体

        // 创建一个新的缓冲几何体(BufferGeometry对象)
        const geometry = new BufferGeometry();
        // 如果位置数组非空,将其作为点的位置属性添加到几何体中
        if (position.length > 0) geometry.setAttribute('position', new Float32BufferAttribute(position, 3));
        // 如果法线数组非空,将其作为点的法线属性添加到几何体中
        if (normal.length > 0) geometry.setAttribute('normal', new Float32BufferAttribute(normal, 3));
        // 如果颜色数组非空,将其作为点的颜色属性添加到几何体中
        if (color.length > 0) geometry.setAttribute('color', new Float32BufferAttribute(color, 3));
        // 如果光照强度数组非空,将其作为点的光照强度属性添加到几何体中
        if (intensity.length > 0) geometry.setAttribute('intensity', new Float32BufferAttribute(intensity, 1));
        // 如果标签数组非空,将其作为点的标签属性添加到几何体中
        if (label.length > 0) geometry.setAttribute('label', new Int32BufferAttribute(label, 1));
        // 计算几何体的边界球,用于进行一些优化和碰撞检测等操作
        geometry.computeBoundingSphere();
        // 构建材质,设置点的大小为0.005
        const material = new PointsMaterial({ size: 0.005 });
        // 如果颜色数组非空,则设置顶点颜色为true,这样点将使用顶点颜色数组中的颜色
        if (color.length > 0) {
            material.vertexColors = true;
        }
        // 构建点云对象并返回
        return new Points(geometry, material);

然后到这里整个代码也就完结了。

3.总结

那我们来总结下Three.js中PCDLoader.js加载点云具体做了哪些工作。

1.load方法加载点云文件 url 地址,加载完得到的data是一个ArrayBuffer对象。

2.解析方法parse开始解析上面传过来的ArrayBuffer对象数据。

3.使用 TextDecoder 解码数据流,将二进制数据转换为文本格式。

4.使用 parseHeader 解析头文件信息,解析完返回解析后的头文件信息对象,这个返回的对象是一个标准的JS对象。

5.如果点云头文件类型为 ascii ,那么就开始解析点云头文件类型为 ASCII 的数据。

6.如果点云头文件类型为 binary_compressed ,那么就开始解析点云头文件类型为 二进制压缩格式 的数据,这里解析的时候调用了解压缩LZF的函数 decompressLZF

7.如果点云头文件类型为 binary ,那么就开始解析点云头文件类型为 二进制格式 的数据。

8.构建点云对象并返回。

相关推荐
meng半颗糖2 分钟前
vue3 双容器自动扩展布局 根据 内容的多少 动态定义宽度
前端·javascript·css·vue.js·elementui·vue3
yt948324 分钟前
jquery和CSS3圆形倒计时特效
前端·css3·jquery
teeeeeeemo5 分钟前
CSS3 动画基础与技巧
前端·css·笔记·css3
年纪轻轻就扛不住8 分钟前
CSS3 渐变效果
前端·css·css3
Aisanyi12 分钟前
【鸿蒙开发】使用HMRouter路由的使用
前端·harmonyos
杉木笙17 分钟前
Flutter 代码雨实现(矩阵雨)DLC 多图层
前端·flutter
SouthernWind19 分钟前
Vista AI 演示—— 提示词优化功能
前端·vue.js
林太白19 分钟前
也许看了Electron你会理解Tauri,扩宽你的技术栈
前端·后端·electron
前端的日常22 分钟前
JavaScript 必看!算法 O 系列全攻略
前端
anganing26 分钟前
Web 浏览器预览 Excel 及打印
前端·后端