基于UE4/Unity绘制地图基础元素-线(上篇)
上篇中记录了绘制线的基本流程,而下篇主要是对绘制线中遇到的性能和效果问题进行阐述。在绘制完一条线并且希望给其加上描边样式时,会遇到不可避免的闪烁问题。而在绘制大量的交错道路时,需要同时考虑绘制性能和闪烁问题如何解决。本文总结了高效绘制描边线的方法,并对调研过的解决Z-Fighting闪烁的方案进行阐述。
在上篇中介绍了逐像素剔除产生圆角的方法,概括的来说,为了达到动态圆滑的目的,将原来CPU中的数学计算移入了片元着色器中进行。这样做虽然能得到最圆滑的效果,却也给GPU带来了压力。以圆角线帽代码为例,受GPU处理方式影响,动态分支的if/else指令需要被全部执行,同时discard指令也会影响GPU的Early Z优化,二者都会对性能产生影响。
fixed4 frag (v2f i) : SV_Target
{
if(i.geometryInfo.x < 0) // 起点侧线帽
{
if(dot(float2(i.geometryInfo.x, i.geometryInfo.y), float2(i.geometryInfo.x, i.geometryInfo.y)) > 1)
{
discard; // 距离圆心距离大于1则剔除
}
}
else if(i.geometryInfo.x > 1) // 终点侧线帽
{
if(dot(float2(i.geometryInfo.x - 1, i.geometryInfo.y), float2(i.geometryInfo.x - 1, i.geometryInfo.y)) > 1)
{
discard;
}
}
return i.color;
}
因此在片元着色器中指令的性能优化上,主要是将其逻辑改为线性,移除动态分支,并以Alpha Blending代替discard。简化流程的主要工具是CG标准函数step/clamp/lerp,其定义如下,灵活运用这些函数就可以规避动态分支。
简化流程后的片元着色器代码如下,通过消除动态分支语句和discard指令减少性能开销,牺牲部分代码的可读性,但提升了并行效率。其中为了确定像素是否属于线帽构造了二次函数,实际上也可以构造其他类型的函数达到目的。
fixed4 frag (v2f i) : SV_Target
{
fixed4 clearColor = 0;
fixed isClear = 0;
fixed origin = clamp(i.geometryInfo.z, 0 ,1); // 两侧线帽x值收缩到0和1
fixed4 isCap = step(0, origin * (origin - 1)); // 构建二值函数,线帽为1,线段为0
fixed2 dist = fixed2(i.geometryInfo.z - origin, i.geometryInfo.w); // 构建距离向量
isClear = step(1, dot(dist, dist)) * isCap; // 距离小于1(不需要剔除)为0,距离大于等于1(需要剔除)且是线帽像素,则为1
return lerp(i.color, clearColor, isClear);
}
根据上篇完成一条线的绘制后,为了使线易于观察,通常需要使得线具有描边样式。实际上,上篇中展示的线已经为了美观都带上了描边,但要让线有描边部分还需要进行额外的绘制。
为了减少顶点数增加并简化三角剖分的计算,通常是在绘制的填充线之下使用描边线宽进行一次同样的扩展绘制,描边线宽构造产生的面更大,使得两个线构成的面叠加展示就可以达到线描边的效果。这种方案的描边宽度为(sideLineWidth - lineWidth) / 2 。
描边线的基本原理如上所述,而在实际的绘制中可以针对填充线和描边线的特性,对渲染逻辑进行优化。在实践中主要进行了以下探索:
可以看到描边线和填充线在绘制时的扩展方向是一样的,差别在于根据扩展向量扩展的线宽不同。因此可以将扩充顶点的计算抽离到顶点着色器中并行进行,数据处理时只计算扩充的基准向量,将其和线宽信息借助uv结构一同传入shader中,这样两部分的线就可以复用同一个Shader进行渲染。但两部分的线仍需要分两次进行绘制,消耗两个Draw Call。
基于顶点着色器的思考,两个线的绘制只有顶点位置和颜色的不同,因此可以模拟Batching操作,将两条线的mesh数据进行合并,就可以在一个Draw Call调用进行绘制。可以看到,在两个mesh的合并过程中只需要对三角形索引根据顶点数进行调整,其余的数据都可以直接合并。
public LineMesh CombineLineMesh(LineMesh appendMesh)
{
int index = this.vertices.Count;
for (int i = 0; i < appendMesh.triangles.Count; ++i)
{
appendMesh.triangles[i] += index;
}
this.triangles.AddRange(appendMesh.triangles);
this.vertices.AddRange(appendMesh.vertices);
this.color32s.AddRange(appendMesh.color32s);
this.geometrys.AddRange(appendMesh.geometrys);
this.parameters.AddRange(appendMesh.parameters);
return this;
}
虽然探索2中已经达到了一个Draw Call进行渲染,但是描边线和填充线是使用两组顶点进行的渲染,本着能省则省的精神,为了减少顶点数,可以考虑在一组顶点中,根据描边线宽和填充线宽的比例信息,一次性绘制出整个线。这种做法需要利用上篇文章中为了绘制圆角引入的geometry信息,x信息可以标识长度,而y值就可以作为宽度方向上的标识。若定义ratio为线宽的比值,则可根据片元着色器中y值的分布确定渲染颜色。
ratio = lineWidth / sideLineWidth
abs(y)∈[0,ratio] -> color
abs(y)∈(ratio,1] -> sideColor
这个方案可以只使用一组顶点绘制完描边线,但也存在一些问题:
1、在线帽和拐角的圆角支持上需要类似同心圆的绘制逻辑,需要再引入额外的条件判断,对逻辑复杂度和性能都有影响。
2、在绘制大量相互交错的线时,线的压盖顺序需要动态的去调整,会遇到一部分交错线的所有填充部分要压盖所有描边部分,而一次性绘制的线是无法支撑这一效果的。
综上,从绘制方式上的改进有其局限性,探索2的绘制方式更为合适。
绘制方案确定以后,在绘制时遇到的下一个问题就是线的Z-fighting问题,即观察时线一直在闪烁。其原因是描边线和填充线重叠部分所在的世界坐标完全一致,坐标转换后受深度缓冲精度影响导致片元在渲染时无序通过深度检测,最终表现为面的闪烁问题。
Z-fighting问题算是绘制线的最后一个障碍,其中涉及许多图形学的基础知识,在探索解决方案的过程中也对渲染的全流程有了更多的认识,探索的方案总结如下:
解决Z-fighting问题的第一步是定位出深度值冲突的对象。在绘制带描边的线这个场景中,导致闪烁的原因是描边线和填充线的重叠部分世界坐标高度值一致,导致坐标转换后片元深度值一致。因此可以在冲突的面的高度值上增加一点儿偏移,通过改变局部坐标影响转换后的深度值,最终可以看到闪烁现象消失。
根据前面的讨论,修改局部坐标的操作可以放在Shader中并行进行,以Unity为例,通过设置一个priority变量用于微调顶点y方向的偏移,从而控制显示的优先级。
fillLineMesh.priority = 1;
v2f vert (a2v v)
{
v2f o;
float4 pos = v.vertex + float4(v.parameter.x, 0, v.parameter.y, 0) * v.parameter.z; // 根据向量和线宽计算实际顶点位置
pos += float4(0, priority / 100, 0, 0); // 顶点y方向进行微调,需要把握微调大小
o.pos = UnityObjectToClipPos(pos);
o.color = v.color;
o.geometry = v.geometry;
return o;
}
这种方式能暂时解决闪烁问题,但在将摄像头位置拉远后仍会出现。其原因是深度缓冲的精度有限,因此距离摄像头越远需要的偏移量越大,微调的偏移量需要根据顶点和摄像头的距离动态调控。在实际操作中,视线方向与顶点微调方向多数情况下并不相同,而在解决大量线重叠的Z-fighting时,大量偏移的累加可能会从视觉上观察到线不共面,与所有线在同一平面的地图展示方式不符,因此方案一通常仅作为初步验证Z-fighting原因的工具。
Unity ShaderLab提供了微调偏移的Offset指令,指令定义和计算公式如下:
Offset Factor, Units
offset = m * factor + r * units
其中m是由系统计算出的多边形深度斜率的最大值,多边形越是与近裁剪面平行,m就越接近于0,r是深度值可分辨的最小单位,是由系统指定的常量。若多边形与裁剪面平行,则可以使用factor=0,units=1的组合控制偏移,而对于与裁剪面有夹角的多边形,需要factor一同控制偏移量的大小,Offset结果大于0会使得多边形远离近裁剪面进行偏移,具体的参数值需要实践过程中进行摸索确认。
使用Offset指令作用于裁剪空间的深度值可以解决多个Object之间的Z-fighting问题,但当为了减少Draw Call将所有线合并为一个mesh后就无法使用了,因此需要借助于其原理手动调控同一mesh中不同线的深度信息。
深度信息是在片元着色器之后计算得到的,因此无法通过着色器的可编程部分直接更改。但深度信息是由裁剪空间的齐次坐标计算而来,因此可以通过操控裁剪空间坐标达到调整深度的目的。
在光栅化之前,坐标会进行模型-视图-投影变换由局部坐标转换为裁剪坐标,其中由观察空间经由投影矩阵变换得到的就是裁剪空间齐次坐标,其后转换为屏幕空间得到的NDC坐标z值由齐次坐标的z/w得来,决定了深度值。由观察空间坐标转换为裁剪坐标需要以下参数:
f:远裁剪面
n:近裁剪面
fov:视角
aspect:摄像机横纵比
设观察空间坐标为 ,
根据深度值规则,在裁剪坐标z值上添加-z*offset的偏移即可将深度向后微调offset大小。在UE4的material中,也可以通过调整Pixel Depth Offset达到偏移的效果。
v2f vert (a2v v)
{
v2f o;
o.pos = float4(UnityObjectToViewPos(float3(v.vertex.xyz)), 1.0);
float z = o.pos.z;
o.pos = mul(UNITY_MATRIX_P, o.pos);
o.pos.z = o.pos.z - z * v.parameter.z/1E8;// 使用parameter.z存储顶点偏移信息
return o;
}
上述方案都是通过在不同的面之间构造微小偏移来解决Z-fighting问题,而另一种思路是不增加偏移,通过指定渲染时的压盖规则,先绘制的面被后绘制的面压盖,最终显示出正确的图像。这种方案需要首先理解深度检测的概念。
深度检测在片元着色器之后进行,每个片元携带自身的深度值与深度缓冲内的深度值进行比较检测,若检测通过,深度缓冲内的值将被设为该深度值。若检测失败,则丢弃该片元。Unity ShaderLab使用ZWrite和ZTest两个指令控制这一过程:
ZWrite On
ZTest Always
对于闪烁问题,前三个探索方案核心都是构造微小偏移,若fighting的面数过多,造成微小偏移大量叠加产生量变,可能会对图形的透视显示大小产生影响,这时推荐使用方案四。而对于多Object的情况,可以搭配方案二与方案四共同使用,效果更佳。
至此,已经解决了绘制线的所有问题,下图使用各种纯色进行了道路线绘制,如果效果不满意,还可以尝试进行纹理贴图,使得道路线更加酷炫。
作者:程序员阿Tu
链接:https://zhuanlan.zhihu.com/p/266042561
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。