///动态SDF字体实现要点//
使用SDF渲染字体最早比较出名的就是Valve在sigraph上的那篇论文了[1],之后又有前辈大神实现了比较实用化的离线中文SDF静态字库[2],到现在新版的Unity通过集成Text Mesh Pro已经可以很好且高效的实现动态SDF字体的渲染了。
对于Unity用户来说使用动态SDF字体来实现描边效果现在也就是勾选一个设置的事儿了。但是背后实现的原理还是非常想要研究一下,一是因为我目前在用UE4但美术却心心念Unity的描边效果,二就是因为当年自己大胆宣判过SDF Font的死刑,自己挖坑还是要自己填。
现在能写出这篇文章,自然是因为Text Mesh Pro已经把这块做好了,别人做好的东西拿出来说感觉是不太好,所以就只讲讲原理,分享一下收集到的相关论文(可能有墙),代码可以从链接的论文中去自己看。实现内容主要在1,2,3里,如果2没看太明白可以先看下4,5。
首先要做动态SDF字体的生成和渲染,Valve那篇论文可以直接扔掉了,静态字库的方案也可以Pass。原因有二:
(左)小号字的大部分点都是非整像素的。(右)TTF实际渲染出来的字符Bitmap是预先做了AA处理的,缩小为实际大小后看上去会很舒服。(中)如果简单的设置阈值进行SDF渲染,缩小后会表现为锯齿与抖动
要想实现可实用化的动态SDF字体渲染,我们就要解决上述的两个问题。解决方案就是对症下药:
基于二值图生成SDF的算法在Valve的文章中并没有详细介绍,毕竟是离线计算的,即便是用本地空间暴力计算的方式来生成也不是不可接受,但若想实时生成的话就必须要有个在保证质量的情况下速度最快的算法。
后面的小节会对SDF生成算法进行更详细的分析,这里先说一下结论,就是目前时间复杂度是线性的生成算法中,8SSEDT(8-point Signed Sequential Euclidean Distance Transform)应该是综合速度与错误率性价比最高的。另外可选的方案还有Chamfer3x3 DT(错误率稍高,速度稍快)或者4SSEDT(速度很快,错误率偏高)。
单单有这些高效的算法是不够的,原因是这些算法都是基于二值图的,这也是为什么Valve的文章里在生成SDF图时使用的是4096尺寸的图片,本质上就是对于原图进行超采样以得到最精确的像素距离。但我们现在的需求是基于动态输出的字符点阵图来生成SDF,而字符点阵图中的像素经过AA处理后是灰度的,仅仅粗暴的通过设置阈值来区分点阵图中的前景与背景只能得到一个惨不忍睹的结果,跨像素(sub-pixel)的笔画线将不可避免的被处理成粗细不均,乃至更小的细节会直接丢失掉。
(a)一个圆点进行光栅化后的灰度图,虚线表示实际轮廓线。(b)放大局部区域可以看到轮廓线穿过的像素有不同的灰度值。(c)虚线为粗暴二值化后计算的距离,实线为正确距离,可以看出二者有明显的差距
因此我们需要还需要一个算法能够计算出像素点到真正轮廓线的最短距离,以修正常规的基于二值图的DT算法。这个算法可以说是SDF文字实现动态化的核心了,算法的名字叫做 Anti-aliased Euclidean Distance Transform。论文链接献上,直接看4、5节算法描述及优化就行,文末有源码链接。
Anti-aliased Euclidean Distance Transform - Stefan Gustavsonciteseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.504.7099&rep=rep1&type=pdf
这里我就简单概述一下他这个算法的思路:
(左)灰线表示轮廓线的方向,将一个像素分为了三个区域。(中)当轮廓线位于a1区域(0<a<a1)内时真实距离d的展示。(右)推导出来的公式
游戏中动态字体的渲染流程简单来说是这样的:
简单来说这一过程的核心目的,就是将光栅化好的文字点阵直接点对点的渲染到屏幕指定位置上,以到达最好的文字渲染质量。
好文字的光栅化看这张图就知道里面学问可深了,把大厨做好的菜能原封不动的端上桌就是游戏引擎在文字渲染过程中的角色
现在经过上一小节的处理,我们在第一步与第二步之间插入了灰度点阵图到SDF图的转换步骤。这相当于在将文字图集纹理传给GPU前重新把已经光栅化好的字符图像信息还原成了矢量信息,因此光栅化过程中抗锯齿的重担就需要在材质中进行额外处理了。这一步做的好坏也决定了这个SDF Font到底只是个技术玩具还是可以真正应用的字体渲染方案。
在Shader中对SDF纹理采样进行AA处理已经有不少文章写了,很好搜索,这里不做赘述了,直接给出最权威的文章链接,收录于OpenGL Insights:
2D Shape Rendering By Distance Fields - Stefan Gustavsonwww.diva-portal.org/smash/get/diva2:618269/FULLTEXT02.pdf%C2%A0
有没有发现这个作者很眼熟,人家明显就是做事儿做全套的!
由二值图像生成SDF图的生成算法属于图形处理领域中的一个大类问题:DT(Distance Transform)。DT表示将图像中的点的值映射为到目标区域的最短距离的变换(用人话说就是把一张黑白图经过DT处理就变成了SDF图),这是很多图像识别处理的起手式,相关的论文和算法简直是多如牛毛。
根据对距离度量方式的不同,DT还可以分为两个子类:
DT算法总体来说可以分为几大类,一类是光栅化扫描算法(Raster scan algorithms),前文介绍的8SSEDT就属于这个。另一类是传播算法(Propagation algorithms),还有独立扫描算法(Independent scanning algorithms)等等。
光栅化扫描算法简单直观好理解,速度相对也很快,时间复杂度都是O(N^2)。其他的咱也没细看,咱也不瞎说。感兴趣的可以看下面这篇论文 ,7.4节里介绍了所有的EDT算法。
2D Euclidean Distance Transform Algorithms: A Comparative Surveyciteseerx.ist.psu.edu/viewdoc/download?doi=10.1.1.66.2644&rep=rep1&type=pdf
说回光栅化扫描算法,最早都是应用于SWDT的,直到Danielsson[4]提出了8SSEDT,后续Ye对这个算法进行了优化。扫描算法的思想非常简单,用朴素但不太准确的人话来说,就是每个像素到最近轮廓线的距离,可以通过遍历该像素点周围像素,求出周围像素已知的最近距离加上其到周围像素点的距离中最小的那个来得到。通过由左上到右下的遍历得到每个像素左上半边的最短距离,在通过由右下到左上的扫描遍历得到每个像素右下半边的最短距离,综合起来就得到了每个像素到轮廓线的全方向最近距离。
(a)早期的SWDT算法的扫描方式。(b)SEDT算法的扫描方式,多了mask 2和3,但是为了要求得正确的欧几里得距离,这两步必须的,没有这两步会导致斜线方向上的距离计算出现误差。
Ye对于8SSEDT优化的主要点在于原算法中每个点会记录 ,而其优化成了记录 ,在计算基于相邻点的最短距离时以平方距离做最短距离比较 。减少了大量平方根的运算。
另外8SSEDT进行了四趟扫描,而最古老的3x3 SWDT只进行两趟扫描,看上去有两次扫描是多余的,实际上并不是,如果只进行两趟扫描的话对于左上和右上方向的距离计算会相较其他方向出现偏差,导致结果出现误差。但是另一方面,“这两种扫描方式产生误差相较于距离度量上的误差几乎可以忽略不计”(I. Ragnemalm. The Euclidean distance transform and its implementation on SIMD architectures)。
再给出一篇非常好的文章链接,里面详细的分析了这几种主要的扫描线DT算法,并给出了他们的性能开销对比和误差对比。
光栅化扫描算法综述pages.cs.wisc.edu/~dyer/cs766/readings/leymarie-cvgip92.pdf
pu是像素单位的意思,SSEDT产生的是绝对误差,0.09pu可以说是精度非常高了。相对的底下都是相对误差,与轮廓线的距离越远误差越大
最后额外提一嘴静态SDF,如果需求就是大尺寸的字符LOGO渲染,那还是推荐使用静态的SDF的。 但是对于高质量的文字渲染来说,在我看来这个方案有两个点是不太能接受的,一个是圆角问题,第二个是缩小的问题。
缩小问题目前没有看到啥太好的解决方案,但是下面这个MSDF可以使用更低精度贴图来进行高质量的文字渲染,从某方面来说也算是解决了这个问题吧。
圆角问题的解决方案在写这篇文章时发现了一个叫做MSDF(Multi-channel Signed Distance Field)的技术,还没怎么细研究,但是效果看上去非常好,很好的解决了SDF放大渲染直角变圆角以及低精度SDF走样的问题。UE4商店里也看到插件了,先把链接放上回头慢慢研究吧。
https://github.com/Chlumsky/msdfgengithub.com/Chlumsky/msdfgen
这个是七日之都的,可以看见多边形的锯齿恨严重。
崩坏三的好很多
别跟我说是弄好的一张UI图片或者开了抗锯齿…看过关于sdf 用 smoothstep搞,不知道能不能搞出来例如5边形的sdf?不过感觉效率绝对不如美术搞好一张图片贴上去的了
//
//