在WebGL 中绘制直线 - 知乎专栏

文章推薦指數: 80 %
投票人數:10人

之前我们介绍了「 在WebGL 中绘制地图(多边形篇)」,本文会介绍关于直线 ... https://github.com/mattdesl/polyline-miter-util/blob/master/index. 首发于WebGL九浅一深无障碍写文章登录/注册之前我们介绍了「在WebGL中绘制地图(多边形篇)」,本文会介绍关于直线的绘制方法,大致包含以下内容:直接使用原生gl.LINES的问题使用沿法向拉伸后三角化的方法绘制直线接头样式、反走样等常见优化手段Cesium、Mapbox、GeoJS等成熟引擎的实现项目地址:gl.LINES存在的问题在一些场景下,尤其是涉及到地理信息的展示,直接使用原生的gl.LINES进行绘制存在一些问题:线宽无法设置,Chrome下试图设置lineWidth会得到警告,相关ISSUE:MDN:AsofJanuary2017mostimplementationsofWebGLonlysupportaminimumof1andamaximumof1asthetechnologytheyarebasedonhasthesesamelimits.无法定义相邻线段间的连接形状lineJoin以及端点形状lineCap因此我们得考虑将线段转换成其他几何图形进行绘制。

沿法向拉伸常用的做法是沿线段法线方向进行拉伸后三角化。

例如下图中线段两个端点分别沿红色虚线法向向两侧拉伸,形成4个顶点,三角化成2个三角形。

沿原始线段法向拉伸进行三角化这样实现起来很容易,例如deck.gl的LineLayer:deck.gl-LineLayer但是很明显,我们需要在相邻线段的连接处进行处理。

连接接头在CanvasAPI中可以通过lineJoin指定线段接头处的连结方式,同样SVG中也有stroke-linejoin属性。

从上到下分别为rounded、bevel和miter。

lineJoin:rounded、bevel和miter我们以miterjoint斜接接头为例,看看在WebGL中如何实现。

以下思路来自「Smooththicklinesusinggeometryshader」。

首先需要计算切线方向t,然后计算斜接接头方向m为t的法线方向,最后得到线宽(红色虚线)投影到接头方向的长度://https://github.com/mattdesl/polyline-miter-util/blob/master/index.js#L9-L20 functioncomputeMiter(tangent,miter,lineA,lineB,halfThick){ //计算切线 add(tangent,lineA,lineB) normalize(tangent,tangent) //计算接头方向 set(miter,-tangent[1],tangent[0]) set(tmp,-lineA[1],lineA[0]) //半线宽投影到接头方向的长度 returnhalfThick/dot(miter,tmp) }在vs中,根据miter计算点的偏移量://https://github.com/mattdesl/three-line-2d/blob/master/shaders/basic.js uniformfloatthickness;//线宽 attributefloatlineMiter;//miter长度 attributevec2lineNormal;//normalize之后的法线方向 vec3pointPos=position.xyz+vec3(lineNormal*thickness/2.0*lineMiter,0.0);斜接接头还存在一个很明显的问题,如果连结处的夹角很小,就会导致计算出的miter无限大,视觉效果很不好。

例如下图:相邻线段夹角很小导致miter长度过长因此我们可以设置一个miter阈值,超过之后就使用bevel接头,对应SVG中的stroke-miterlimit属性。

相比miter,在三角化时需要增加一个三角形,对于接头处的每一个顶点,内侧仍然使用miter的一个,而外侧需要生成两个:bevel接头除了miter和bevel接头,还可以使用圆角,例如deck.gl的PathLayer开启rounded选项之后:圆角接头对于圆角接头,Mapbox的做法是在CPU预处理时添加大量额外顶点。

而GeoJS完全在shader中实现,代价就是传入GPU的顶点数据量增大(当前顶点、前后两个顶点)。

反走样如果仔细观察我们目前绘制的线段,可以发现边缘处明显的走样现象:早期mapbox采用过一种办法:绘制出多边形后,在边缘使用gl.LINES再进行描边(线宽为2),利用平台对于直线的MSAA。

但是由于不允许设置非1的线宽,不得不移除了这种方法。

相关ISSUE&PR。

如果不依靠基于后处理的几何反走样方式FXAA/MLAA,我们有以下几种方式可以尝试。

增加顶点最直接的办法是增加三角化的三角形数目,例如「Mapbox-DrawingAntialiasedLineswithOpenGL」中就介绍了这种方法,在两侧各增加一对三角形用于渐变:但是这对于渲染性能显然是有较大影响的(顶点数x2,三角形x3)。

对单位法向量插值如果能在fs中获取到当前fragment到原始线段的距离,就可以对边缘进行模糊。

很自然想到之前利用重心坐标实现wireframe。

但是这种方法有一个明显的缺陷,在mapbox的一个ISSUE「Antialiasfillswithoutgl.LINES」中有清晰的展示,即不同于wireframe,我们只想处理三角形的某一条边而非三边,并且即使我们找到了这条边,也还会缺少一块:还记得在vs中我们计算出的normalize之后的法线方向吗?利用varying插值,在fs中获取到单位法向量的插值结果,显然插值后就不是单位向量了(如下图),但我们可以利用向量长度作为模糊因子。

对单位法向量进行插值varyingvec2v_normal; floatblur=1.-smoothstep(0.98,1.,length(v_normal)); gl_FragColor=v_color; gl_FragColor.a*=blur;效果如下,可见边缘的走样现象缓解了很多:反走样效果前后对比Prefiltered除此之外,还有基于查找表的预过滤方式。

基本思路是首先计算出拉伸后的两个edgefunction,离线将卷积(box、Gaussian)结果存储在纹理中,在运行时将当前fragment位置带入edgefunction得到距离原始线段的长度,再查表得到卷积结果:「GPUGems2-FastPrefilteredLines」「FastAntialiasingUsingPrefilteredLinesonGraphicsHardware」「PrefilteredAntialiasedLinesUsingHalf-PlaneDistanceFunctions」解决了走样问题,我们再来看几个常见问题。

在GPU中进行墨卡托投影如果投影变换是在GPU而非CPU中进行的,例如deck.gl,在使用原始经纬度坐标计算出法线方向后,还需要在shader中进行转换,例如://u_pixels_per_meter表示在当前经纬度点,一度对应多少像素 vec3offset=normalize(vec3(lineNormal,0.0)*u_pixels_per_degree) *thickness/2.0*lineMiter,0.0;由于和缩放等级相关,在每次相机发生改变时,在CPU中需要重新计算并传入u_pixels_per_degree:constworldSize=TILE_SIZE*scale;//当前缩放等级下的像素尺寸 constlatCosine=Math.cos(latitude*DEGREES_TO_RADIANS); constpixelsPerDegreeX=worldSize/360; constpixelsPerDegreeY=pixelsPerDegreeX/latCosine;固定线宽2D的线在正交投影下可以保证一致的宽度,但是在透视投影下就无法保证了。

在某些需要时刻保持线宽一致的3D场景下,例如Cesium在地形图中展示滑雪路线:常见的做法是投影到屏幕空间,这就需要将之前在CPU中对于法线和miter的计算挪到vs中进行。

通过除以w分量转换到NDC坐标系,再乘以宽高比就得到了屏幕空间坐标:vec2project_to_screenspace(vec4position,floataspect){ returnposition.xy/position.w*aspect; }由于WebGL不支持GeometryShader,因此除了当前顶点位置,还需要将前后顶点的位置一并传入顶点数据:attributevec3position; attributefloatdirection; attributevec3next; attributevec3previous; //currentPos、prevPos、nextPos已经通过mvp矩阵投影到裁剪空间 //投影到屏幕空间 vec2currentP=project_to_screenspace(currentPos,aspect); vec2prevP=project_to_screenspace(prevPos,aspect); vec2nextP=project_to_screenspace(nextPos,aspect); //计算切线和miter vec2dir1=normalize(currentP-prevP); vec2dir2=normalize(nextP-currentP); vec2tangent=normalize(dir1+dir2); vec2perp=vec2(-dir1.y,dir1.x); vec2miter=vec2(-tangent.y,tangent.x); //投影到miter方向 len=thickness/dot(miter,perp); vec2normal=vec2(-dir.y,dir.x); normal*=len/2.0; normal.x/=aspect; //得到最终偏移量 vec4offset=vec4(normal*orientation,0.0,1.0);基于Three.js的实现:https://github.com/spite/THREE.MeshLine也采用了类似的做法。

Codrop上有一篇使用它实现各种动画效果的教程,感兴趣的也可以阅读下。

虚线「Shader-BasedAntialiased,Dashed,StrokedPolylines」中使用的方法较为复杂。

这里我们采用一种较为简单的实现,同样还是利用varying插值,在CPU中对顶点数据进行预处理,对于每个顶点计算总顶点数的占比。

但是缺点也很明显,虚线并不是按长度等分的。

如果想做到按长度等分,就需要计算每段长度和总长度的占比,相应的会加重预处理顶点数据的负担:varyingfloatv_counters;//占总顶点数比例 uniformfloatu_dash_offset;//控制起始点,SVGstroke动画中常见 uniformfloatu_dash_array;//控制虚线疏密 uniformfloatu_dash_ratio;//控制每小段可见比例 gl_FragColor.a*=ceil(mod(v_counters+u_dash_offset,u_dash_array) -(u_dash_array*u_dash_ratio));效果如下:虚线效果总结绘制直线并不是一件简单的事,尤其是考虑到绘制效果和性能。

我们以上的讨论也只是集中在lineJoin连结方式上,线的很多其他属性例如端点处的样式lineCap并没有涉及。

另外除了直线,还可以使用弧线连接两点,例如deck.gl的ArcLayer。

值得一提的是GeoJS对于直线的各种属性支持做的很好,除了lineJoin(miter|bevel|round)、lineCap(butt|square|round),包括miter的阈值、反走样(模糊半径)都可以进行配置。

而且从内部实现来看,完全在shader中进行,不依赖CPU对于顶点数据的预处理,还是很值得进一步研究的。

GeoJS中对于直线的配置项参考资料基础算法:「DrawingLinesisHard」「Smooththicklinesusinggeometryshader」「JournalofComputerGraphicsTechniques-Shader-BasedAntialiased,Dashed,StrokedPolylines」反走样相关:「GPUGems2-FastPrefilteredLines」「FastAntialiasingUsingPrefilteredLinesonGraphicsHardware」「PrefilteredAntialiasedLinesUsingHalf-PlaneDistanceFunctions」「Mapbox-DrawingAntialiasedLineswithOpenGL」一些成熟3D引擎的实现:「Three.jsMeshLine」「Cesium-RobustPolylineRenderingwithWebGL」「GeoJS-DrawingLinesinGeoJS」「GeoJS、Mapbox、Leaflet效果对比」「EfficientWebGLstroking」编辑于2019-03-1723:27WebGLGIS(地理信息系统)计算机图形学​赞同79​​10条评论​分享​喜欢​收藏​申请转载​文章被以下专栏收录WebGL九浅一深用图形技术打造极致惊艳的互联网体验



請為這篇文章評分?