3个月前,我写过一篇关于性能优化的方法论(《前端性能优化思想模型,在自动驾驶领域的实践》),里面有提到过,我对PCD文件进行二进制转码处理后,效果非常好。
但那篇文章主要是分享方法论和思想模型,并没有展开聊细节,所以估计很多入门小伙伴看都懒得看就划走了,或者看完没有太多感觉,糊里糊涂的也没多大收获。
我写的东西晦涩难懂,帮不到大家,这怎么能行?这是我的耻辱!
那么今天我就拿其中一个案例来展开聊聊,用最通俗的语言,给你抽丝剥茧讲明白。
先看效果!
转码前后文件尺寸对比:(17.8MB vs 4.6MB,压缩率75%)
转码前页面加载效果:(ASCII编码,2倍速播放,18秒)
转码后页面加载效果:(二进制编码,2倍速播放,5秒)
之前也提到过,在自动驾驶点云标注场景下,一次需要加载几十帧的数据文件,如果每一帧文件都是动辄十几二十MB,那即便做异步加载,等待时间之久也是相当令人头大的。
性能优化迫在眉睫!
好,我们先来盘点一下前端手里能用的几个性能优化法宝:
1. 异步加载
2. 分片加载,增量渲染
3. 资源文件压缩
4. 缓存
本文暂且只讲3,124就先跳过不聊了,之所以摆在这里是想给大家一点启发,告诉你,还有这么些个优化方法呢,感兴趣的评论区交流,最好是关注我,追更,也给我一些动力。
来,我们继续。
聊到文件压缩,不得不提一件有意思的往事。很多人问我,我网名为啥叫ASCII26?
那是因为,大学时候学到著名的哈夫曼编码(Huffman Coding),老师给我们布置了一道作业,用哈夫曼编码压缩一段超长文本,比如一部小说。
我觉得这事儿很有意思,就吭哧吭哧开始写算法,写完一运行,文件确实压小了不少,正得意呢,突然发现,压缩文件反向解码的时候出错了,解出来的文件出现了乱码。
这个问题让我定位了好久好久,没日没夜反复调试,怎么都找不到bug在哪。直到有一天,我发现我编码的文本里有一个鬼东西,原文中肉眼不可见,编码后是一个极其容易被忽视的小红点,我用代码读它,发现这东西的ASCII编码值是26。
当然了,是什么原因导致的bug,以及我怎么解决的,我都记不清了(已经十多年了),我只记得,从那天起,我把所有的网名全都变成了ASCII26,算是一个纪念吧,纪念我呕心沥血独自解决了一个难题,也纪念我对编程这件事仍充满着热爱。
扯这个小故事给大家放松下,同时,这个小故事本身也是编解码相关。
好啦,回到正题。
那么这次我们要编解码什么呢?我们先来看下我们要处理的文件长什么样。
这就是PCD文件(自动驾驶点云文件)的冰山一角,其中,1-11行是它的标准头部信息,而12行之后,便是无穷无尽可随意扩展的点云数据。
这里简单提一嘴,有的产商提供的点云数据直接就是bin文件,而有的是pcd文件,还有的甚至是JSON文件,总之,国内的自动驾驶行业现状非常混乱,工程团队素质良莠不齐。
知道PCD文件头部元信息之后,我们把它取出来备用,这一小部分并不会占用太多体积,压不压缩都无所谓,压缩反而不利于后期直接在ThreeJs里引用。
而真正影响文件体积的,是12行之后的点云数据(几十万行),我们观察这一部份的构成,每一行是一个点的信息,用空格分割,分别代表着x,y,z和intensity(头部元信息告诉我们的)。
那么我们要做的就是逐行扫描点云数据,分别将4个参数转写为二进制数据,存入 DataView 中,再使用NodeJS文件流API createWriteStream 将数据写入目标文件,核心代码如下:
// ...省略部分代码
const PARAMS_LENGTH = 4; // [x, y, z, i].length
const AXIS_BYTES_SIZE = 4;
const POINT_BYTES_SIZE = PARAMS_LENGTH * AXIS_BYTES_SIZE;
// 创建一个dataView,窗口长度初始化为 点云数量 * 16 (从头部文件信息可知,一行4个参数,每个参数占4个字节)
const dataview = new DataView(new ArrayBuffer(POINT_BYTES_SIZE * points.length));
points.forEach((pointString, rowIndex) => {
// 将点从字符串中取出来,即'x y z i' => [x, y, z, i]
const point = pointString.split(' ');
// 逐个参数处理,塞入dataview中
point.forEach((axis, axisIndex) => {
dataview.setFloat32(rowIndex * POINT_BYTES_SIZE + axisIndex * PARAMS_LENGTH, Number(axis), true)
})
})
const wstream = fs.createWriteStream(output, 'binary');
// 写回头部信息
wstream.write(getPCDHeaderString(points.length))
// 写入点云信息
wstream.write(Buffer.from(dataview.buffer))
wstream.end(() => console.log('写入成功!'));
原始代码涉及到一些PCD文件头处理等工作,由于这一部份和业务强相关,和本文所聊的压缩关系不大,所以我就把相关代码删了,只留下二进制转码相关的核心代码。
代码很短,应该不难看懂吧?看不懂的评论区举个手,我来给你手摸手教学下。
懒得看或者看不懂的,我作为课代表帮大家把关键点挑出来了,一起看看吧:
1. DataView
2. CreateWriteStream
3. Buffer.from(dataview.buffer)
我先帮大家捋一捋整体流程,大致如下:
为什么我们没有用理想操作模型呢?两个文件流pipe一下,中间加一个转换器做一个编解码,搞定!多简单呀。
那是因为,当前这个场景不合适。
ThreeJS天然支持PCD文件的渲染,但前提是,必须有标准头,也就是这个东西。
有了头部元信息之后,剩下的部分就是「点云二进制」数据,ThreeJS天然支持。可以看下对应的源码:(https://github.com/mrdoob/three.js/blob/master/examples/jsm/loaders/PCDLoader.js)
既然如此,那我们就可以把一个带有标准头的二进制文件直接丢给ThreeJS去渲染即可,ThreeJS会在运行时去解析,我们无需在服务端或者前端做多余的「解码」操作,节约渲染成本。
而如果采用理想模型,这意味着我们在转码Stream的每一个chunk的时候,是直接将chunk转成了二进制,并没有按「点」为单位的去处理,毕竟NodeJS的chunk是按某个固定字节大小来分片的,而不是定制化的按「点」为分片单位。
这样的话,最终转出来的,仅仅是一个二进制文件,而不是一个ThreeJS可以识别的「点云二进制」文件,我们就必须在渲染之前先处理一遍数据,这就不太合适了。
当然了,如果是那么简单就能做的,也就不会有今天这篇文章了,也是因为这个特殊的处理场景,引入了一些比较有意思的知识点,所以才想分享给大家。
首先想想我们为什么要用DataView?DataView又是什么?一起看下MDN的解释:
DataView 视图是一个可以从二进制 ArrayBuffer 对象中读写多种数值类型的底层接口,使用它时,不用考虑不同平台的字节序(endianness)问题。
我们需要以「点」为单位做编码,写入文件,那么也就是说,我们需要操作文件Buffer,而NodeJS为了防止安全和内存泄漏问题,是不允许直接操作Buffer本身的,于是提供了一个DataView接口,非常方便的操作ArrayBuffer。
我们可以将每个「点」的信息逐一写入DataView:
point.forEach((axis, axisIndex) => {
dataview.setFloat32(rowIndex * POINT_BYTES_SIZE + axisIndex * PARAMS_LENGTH, Number(axis), true)
})
这里我们用了setFloat32 ,因为xyzi四个参数每个都是4字节的浮点数,所以用float32来存,第一个参数是位偏移量,第二个是需要存的值,第三个是字节序(可选)。
dataview.setFloat32(byteOffset, value [, littleEndian])
对所有的点逐一处理之后,我们就拿到了一个存有完整点云信息的DataView,就可以拿去写文件了!
But!这里又有关键点了。你以为直接拿DataView的Buffer写入文件即可,如下:
wstream.write(dataview.buffer)
然后你就会看到报错:
为什么呢?
DataView的Buffer难道不是Buffer么?还真不是!你把鼠标挪到代码上一看:
好家伙,是个ArrayBuffer!没错,官方文档一开始就说了,DataView就是拿来操作ArrayBuffer的,没毛病。
那么怎么处理呢?其实稍微改一下就好啦:
wstream.write(Buffer.from(dataview.buffer))
用 Buffer.from 就可以直接把buffer转出来。
你可能有疑问了,从arrayBuffer转buffer我会了,那反过来呢?哈哈,其实一开始我们读源文件拿来做点云信息解析的时候,就已经这么干了,被我省略了代码,这里补上:
const inputData = fs.readFileSync(input)
const ab = inputData.buffer.slice(inputData.byteOffset, inputData.byteOffset + inputData.byteLength);
看到没有,读取文件数据后,我们拿到的是一个buffer,而从buffer转arrayBuffer,只需要一个小小的slice即可,是不是很神奇。
到这里,其实我帮大家挑出来的关键点就都扯清楚了,最后还剩一个疑问,为啥要用createWriteStream 来写文件?有什么特殊考虑吗?
没错,其实一开始是想着,用「流」来写文件,性能更好,不会占用大量内存空间。
但是上面也说了,我们不能简单的对接两头文件流,必须得把「点」数据读出来逐个编码,那么势必就需要产生Buffer占用空间了,这样的话,Stream其实就变鸡肋了,完全可以直接替换成 fs.writeFileSync,而且writeFile还可以直接写DataView,更省事了呢!
fs.writeFileSync(output, dataview)
帮大家总结一下今天的几个小知识点:
1. NodeJS提供了一个DataView,用来操作Buffer,确切的说是 ArrayBuffer。 2. Buffer和ArrayBuffer可以互相转换。 3. Stream可以节约Buffer占用内存空间,但如果不利用好pipe,就等于失去了它的优势,还不如直接file操作。
好啦!今天的分享就到这了,你学废了吗?