WebGL文字渲染的那些问题
THREE.js开发的应用运行在iphone5下发现有些时候会崩溃,跟了几天发现是因为Sprite太多频繁更新纹理占用显存导致的。通常解决纹理频繁更新问题就要用到one draw all方法,放到纹理上就是把所有纹理图片生成一张大图片的方式
一、阻止纹理重复上传
我们需要一张大纹理,先将所有的内容绘制在大纹理上,需要显示局部纹理的时候通过纹理坐标控制去大纹理上取图像。那么这个时候问题来了,THREE.js内部实现方式是将Texture与图片、纹理坐标绑定,即使为所有的Texture对象设置同一张图片,THREE.js仍然会将每个Texture中的图片上传给GPU。每次上传一张大纹理严重阻塞UI渲染进程。
首先要解决的是让这张大纹理值上传一次。
这个问题需要我们对THREE.js源码进行深入了解,可以看到setTexture2D函数中有一个properties变量,这个变量是一个WebGLProperties类型的变量,而该类型存储各种东西:Texture、Material、RenderTarget、Object的buffers等。我们继续深入该类的源码,发现get方法会根据对象的uuid来获取相关WebGL属性,比如gl.createTexture、gl.createBuffer创建的各种缓冲区。
对应Texture得到的webgl属性如下,其中__webglTexture就是对应的纹理图片创建的缓冲区对象。
那么我们可以来一个取巧的方法,将所有纹理的的uuid都设置唯一,那么THREE.js只会对第一个Texture的纹理进行上传,后面的texture对象取到的都是第一个的properties,这样就能避免纹理重复上传。
二、建立纹理索引
我们需要自己维护一套索引关系,通过这套索引关系得到每个贴图在大纹理中纹理坐标。这里要为每一个poi记录它的起始位置和区域范围,其中要用到canvasContext.measureText来测量文本的宽度,文本高度可以直接根据fontSize取得。
同时索引建立完毕后,需要计算每个poi区域在全局纹理中的纹理坐标范围:
要注意的是,这里纹理坐标的原点在左下方,有时候原点在左上方。建立索引代码如下
三、局部更新
上述方案虽然能够避免频繁上传纹理,但是需要每次将需要绘制的内容准备好,当有内容需要更新时,还是需要重新上传整个全局纹理,反而使得性能下降巨大。经过查阅资料后发现webgl中有一种局部纹理更新技术,简单来说先在内存中开辟一块的纹理区域,将所有内容绘制在这张全局纹理中,每次有更新时,只需要更新它的一个局部区域即可。
但是这里要解决的问题是THREE.js并没有提供局部纹理更新的方式,也没有相应的自定义接口,那么这时候就需要我们自己来处理了。
这里自定义一个Texture的子类
开辟一块内存区域
在需要的时候动态更新局部纹理,其中src这里是ImageData对象
具体代码可以参考这里,我这里也是基于它来定制的。
https://github.com/spite/THREE.UpdatableTexture
原文作者通过更改THREE.js源码的方式实现,而我是直接把下面这个函数拷贝到这个子类中
四、高清屏的大坑
现在我们的方案是,先在gpu中开辟一块全局纹理区域,然后绘制时将poi绘制到一张与全局纹理同样大小的canvas上,然后从canvas中调用createImageData来获取像素,将像素局部更新到gpu中。那么在pc上我们得到的结果很完美。
然而放到移动端上后,我们得到的结果是:
TMMD中间那块哪去了!找了大半天发现问题出现在高清屏上,挡在高清屏上绘制canvas上时,我们通常会做一些高清处理,比如四像素绘制一像素。
我们做高清处理的方式是利用radio*radio设备像素绘制一css像素,看起来是css像素的大小,但实际在浏览器内部,看起来css上一像素实际在canvas里的像素是radio * radio(radio代表window.devicePixelRatio)
但实际上在浏览器内部绘制canvas图像的单位是设备像素。那么如果我们还以上面的rectW、rectH来获取像素的话,我们得到的这部分像素并不是这个poi真正占有的像素数目。
所以,问题就来了我们需要在gpu开辟的全局纹理的单位跟canvas中获取像素的单位要保持一致,我们统一使用设备像素。
我们对canvas也不用使用style来设置样式宽高了。
那么获取poi图像的真正像素范围时:
所以利用getImageData取像素时候,就要小心取到真正的像素区域,(startX * radio,startY * radio)- (poiRectW * radio, poiRectH * radio);否则某些像素就会被丢弃掉,这部分像素才是浏览器真正使用的设备像素。
现在在移动设备上能够获取正确的高清label啦!
五、局部更新引起的新问题
当全局纹理被占满时候,在继续绘制poi,这时候新的poi区域需要更新到gpu中,那么也就带来了新的问题,在gpu中的纹理还保持着之前的像素,而新的poi会覆盖这部分区域,但有时候往往会与之前的文字叠加起来,效果如下:
可以看到新更新的poi,在计算纹理坐标时候,有一部分像素包含了其他poi的像素。这个问题是因为新poi的区域刚好叠在了先前poi的边界上,那么我们只要给新的poi加一点buffer,这个buffer是白素透明区域,buffer会把之前的poi像素覆盖掉,而我们计算纹理坐标时,只取poi的边界,那么就可以解决这个问题。
那么首先绘制的时候就要保留buffer
上传的时候使用buffer
计算纹理坐标时,排除buffer
六、局部更新带来的性能问题
根据目前的结果,局部更新能后解决crash的问题,但是带来了严重的性能开销,与同事应用局部更新提升性能的结果相反。这个问题还要继续跟踪。
目前发现问题是因为使用了getImageData来获取数据,然后传递到gpu中,非ios设备用这种方式有时候getImageData的开销特别大,而ios设备相对好一些。
测试发现非ios设备直接上传一张大纹理的效果反而比getImageData这种方式更好。但是依然不如之前上传多个canvas的性能。而在iphone5的测试机和iphone6的机器上性能比之前直接上传多个canvas的方式好一些,且没有崩溃问题。但是在岳阳的iphone6 plus 16g内存的手机上发现用具局部纹理更新性能很差,而且经常崩溃。
后来发现原因是因为,虽然getImageData在IOS上性能好过非IOS设备,但性能开销仍然比较大,所以当场景中POI很多时,仍然会引起主线程卡顿,甚至计算太密集引起浏览器崩溃。其中层尝试使用cesium方式,每个poi创建新的canvas,将canvas进行局部上传,本以为这种方式不需要getImageData会更快一些,然而实践发现每次创建canvas设置参数的过程更耗时。
最终的方案是仍然使用getImageData,但是将getImageData的过程分块处理,每50ms处理一次,分块放到场景中,这样就解决密集计算引起的崩溃问题,虽然增加了控制成本,但是能够有效解决IOS崩溃问题。有趣的是在安卓上getImageData方式开销很大,即使分块也不适合,而且安卓用一张大纹理的方式来处理,会发现很多POI绘制效果不好。
最终方案是,IOS使用getImageData局部纹理+分块加载方式绘制POI。安卓使用POI独立创建canvas+全量加载方式。(安卓不适用分块加载,是为了尽快把所有POI呈现给用户)
七、文字黑色描边问题
这个问题自始至终困扰我好久一直没找到黑边的原因;
将原始的canvas导出后发现这是因为原始的canvas就有一层边界
曾经怀疑是minFilter的设置不对在pc端纹理使用NEARESTFilter方式取值发现的确能够消除黑边,然而移动端仍然会出现黑边,最后使用颜色混合公式解决问题。
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
在Three.js中需要设置SpriteMaterial的blending为CustomBlending
八、颜色混合新问题
但是使用上述方式同样引来新问题,设计反映poi的icon四周被裁切掉,
看着没问题是吧,设计同学截了图之后放大了20倍。。。。。
刚开始我确实以为这是webgl渲染问题,后来仔细考虑了下这外圈白色的由来(遇到问题还是得静下心分析)。
原因是设置了blendFunc(SrcAlphaFactor,OneMinusSrcAlphaFactor)导致有些icon周围的像素alpha比较低
颜色混合后增加了target的颜色分量,导致最终这些区域的颜色范围接近255,所以泛白。从而把原来图片四周有切边的问题充分暴露出
解决方法是设置alphaTest,如果原始纹理的alpha小于这个值则直接discard。最终得到的效果是:
九、TextureAlta问题
前面因为sprite的旋转中心只能放在sprite纹理区域的中心所以,上面做了很多冗余纹理,有很多空白区域,目前改造了Sprite加了pivot可以动态改变选中中心点,改变后IOS下纹理的使用率提升了60%,安卓下因为是单个纹理上传所以,需要保证纹理的大小是2的n次方,纹理的浪费率降低了50%
上述问题虽然解决了崩溃问题,但是实际使用中每个poi都要getImageData和texSubImage2D这个方法,造成单个poi耗时基本在25ms(iphone5 8.4.4);虽然上面使用setTimeout 50ms分块方式上传,但是如果poi过多比如1000多的停车场,这样会导致停车场数据需要50s才能完全显示出来。这次优化的方案是等待所有poi图片拿到后,绘制所有的poi把画布调用一次getImageData和一次texSubImage2D上传到gpu,同时下次更新时,只会增量一次性上传更新。
十、Frstrum增量更新
原来是在每一级别缩放时把所有的poi都生成好,现在的做法是只生成视锥体中能看得到的poi,然后在每次OrbitControl出发change事件时根据视锥体判断poi,做去重后增量更新
目前还是有些问题,有时候会碰到视锥体中的poi很少,可能是判断问题,后续会加入空间索引,根据索引和视锥体结合起来做增量更新
后续使用发现在停车场这种大数据的poi全部加载到地图下,使用这种方式每次都要做去重处理,性能开销很大,处理方式是使用{}做hash代替数组includes方法,结果发现性能提示很大,原来3600个节点每次去重处理在iphone 16g 10.3.3上性能基本在28帧每秒,经过优化后数据帧率达到50+(主流iPhone7fps60);iphone5 16g 8.4.1 性能在24左右优化后帧率在44+,安卓华为荣耀9优化前25帧,优化后 40+
安卓之所以不适用IOS的绘制方式,是因为这种在安卓上的绘制效果不理想,被设计挑战
安卓后面也做了一些优化,之前安卓是每次都会重新创建canvas并上传至gpu纹理中,导致使用视景体增量更新poi时,性能有所下降,后来每一层中的poi都根据icon、文字组成key缓存起来,并且缓存纹理,不但阻止canvas的重复创建,还阻止canvas重复上传至gpu纹理(three中使用同一uuid),使用该方案荣耀9的fps达到50+
十一、text glyphs
该方式还有待尝试
https://webglfundamentals.org/webgl/lessons/webgl-text-glyphs.html
十二、真正解决POI文字黑边问题
由于要做poi渐变出现效果,但是因为之前处理黑边问题用的是颜色混合的方式,所以当动态改变透明度时,受颜色混合影响往往是文字颜色先消失,剩下透明度部分还存在显示先过很差。所以要实现渐变效果,不能使用颜色混合方法,但不适用颜色混合就会有黑边问题,所以要从源头上解决黑边问题。(看到最后会发现有残影)
那么思考黑边到底是怎么产生的,这与webgl中纹理插值的颜色有关,有的设备像素取纹理时有不同的方案,但一般情况下纹理像素和设备像素都不是一一对应,所以有插值取值问题。
这是正常情况下利用canvas绘图时背景颜色不设置,那么可以看到我们绘制出来的canvas的确有一层奇怪的黑边。当设备取到纹理中这些边界时就会产生黑边。那么就要思考怎么不让它取到这层黑边,这个问题想了好久曾经试过用opacity过滤,发现不能解决问题。
有一天突然想到如果canvas背景为有颜色,每个设备像素都能取到颜色,那么就不会有这个问题。所以我们能否通过改一下canvas的背景颜色同时有通过透明度过滤掉不合格的像素?最终发现这个问题还真可以。
首先在绘制时将canvas背景设置为白色,但是有很低的透明度
这时候canvas绘制出来的效果是
可以看到已经没有黑边了,那么这时候设备像素永远不会取到黑色边界,也就彻底解决了黑边问题。
那么就可以利用tween来做动画了
![](http://images2017.cnblogs.com/blog/412020/201712/412020-20171216224224593-1205430224.png)