基于2D SDF的体积字实现

作者主页:https://www.zhihu.com/people/yesbaba/posts

概述

笔者很久之前就想做体积字,并认为在VR中应该是标配,但直到今天也没流行起来,不过倒是看到了Clay Book、Nex Machina这种基于3D SDF体渲染的炫酷游戏。笔者最近为项目加入TextMeshPro,正好基于TMP尝试一下,直观效果见上面的题图。文字形状不是基于字形Mesh的,而是Signed Distance Field的2D图,模型只是Cube,用于提供Pixel Shader的执行来通过Sphere Ray Marching的方式计算屏幕像素是否在体积字内。优点是对显存的占用很低,一个字形只用几K字节,64x64的A8是4K字节,32x32的效果也是可接受的,只计算形状并对正面侧面分别指定颜色的话计算量不大,且主要消耗在文字侧面的计算上,降低文字厚度和调整视角可以减少计算量。复杂的费性能的效果也可以往上加,可以用Triplanar mapping的方式给前后面和侧面加贴图,通过SDF下降梯度能算出侧面法线,整个体积字可以进行光照计算,正面和侧面可以进行圆角的形状和法线过渡,更进一步计算任一像素的切线空间可以加入法线贴图,之后就支持各种常规表面材质了。

TMP Shader的作者之一(好像是负责写Shader)sschaem在2014年就做过的体积字效果,社区反响不强烈,加之别的功能需求更重要一直没加进去,效果更牛的VRTFX (像是VR Text FX的意思)一直没见发布,参考:

https://forum.unity.com/threads/wip-vrtfx-volumetric-rendering-titling-effects.440048

现在TMP组件里虽然有“Enable Volumetric Setup”,也只是把2D片变成了Cube,仍然没有Shader支持。其实在现有TMP的基础上实现体积字还是挺容易的。本文主要讲述形状计算、字体法线、表面纹理、形状法线和法线纹理,并讨论下相关问题,欢迎大家指正。浏览本文前最好对SDF和raymarching有所了解,比如看这2篇文章:

Volumetric Rendering - Alan Zucconi

https://www.alanzucconi.com/2016/07/01/volumetric-rendering

Ray Marching and Signed Distance Functions

http://jamie-wong.com/2016/07/15/ray-marching-signed-distance-functions

一、形状计算

1.1 SDF数据

文字轮廓用SDF 2D贴图存储,里面每个Texel存的是到最近的文字轮廓的距离,无论是什么方向,轮廓外为负值,轮廓内为正值,运行时对于任意UV通过双线性插值仍然能算出比较精确的距离。在TMP中采用A8格式存储,轮廓是0.5,小于0.5是外侧,从0到0.5对应Padding个像素范围,Padding越大则基于边缘的效果范围就越大,生成字体文件时也就更费计算。

如上图,TMP自带SDF字体是用1024x1024,每个字形精度是86像素,padding是9,算下来就是每个字符有86+9x2的像素精度。

灰色渐变部分表示的就是距离

1.2 体和坐标系

2D SDF拉出一个厚度能表示一个3D场,可以对应一个Cube模型,长宽高可以不相等,“显然”于模型内的任一点都能算出是否属于体积字之内。TMP生成的2D片局部坐标都是在XY平面上的,正面朝向负Z,见下图:

坐标系,顺便看下PointSize 29, Padding 2的汉字效果

在局部坐标系正Z的方向上加入字符的另外4个点,组成Cube,请记住这个坐标系便于以后理解,XYZ对应红绿蓝。

1.3 大概算法

体积字的每一个像素点都包含在Cube中,所以从Cube表面每个像素点对应的坐标开始,延视线方向进行RayMarching,步长是跟当前坐标在SDF图里的对应值相关的,SDF里记录了到最近轮廓的距离,但没有方向,如果距离轮廓足够近则认为到达体积字表面,否则采用新的步长继续Marching。

上图为Sphere raymarching示意,居然是GPU Gem2 Ch8的图,好久远。

如果已经March到了Cube Bound外部,就认为是在体积字外部的。判断内外部的时候最好在cube的局部坐标系里,平移后使左下角顶点为原点,其他点都在XYZ的正方向,对于任一点p,p * (bound - p).xyz有小于0的分量就是在外部,需要被Clip掉。一个TMP组件里可能包含多个文字,它们都在一个局部坐标系里,每个字符都得转到字符局部的坐标系计算。

如果用光了所有步长还不够接近,也认为是到达体积字表面,一般是因为视角近似平行于轮廓表面,造成步长太短,起始已经很接近轮廓了。

对于位置p需要转到为SDF贴图的UV来采样距离值,假设Cube右上、左下点的坐标和uv分别为posTR, posBL, uvTR, uvBL,只关心xy,不关心z

那么uv = (p.xy - posBL) / (posTR - posBL) * (uvTR - uvBL) + uvBL;

模型坐标p到SDF uv的转换示意图

定义sdfUvScale = (posTR - posBL) * (uvTR - uvBL)

在得到SDF距离后需要转换为cube中的距离,看TMPshander中padding + 1个像素对应的是0-0.5的SDF距离值,padding+1被定义为_GradientScale,那么SDF距离可以转换为uv范围,进一步转换模型距离:

sdf2len = 2 * _GradientScale / (max(_TextureWidth * sdfUvScale.x, _TextureHeight * sdfUvScale.y))

1.4 Shader中所需的单个字形的数据

在高版本的OpenGL和DirectX中可以为每个顶点指定InstanceId,一个字形Cube的8顶点对应一个实例,字形数据存在Instance相关的buffer中。如果不支持这种特性就得每个顶点都存储字形数据,通过Normal、Tangent、UV通道传到Shader中,本文就是这么做的。

采样SDF贴图需要对LocalPos.xy缩放和偏移,共4个Float。采样Diffuse等纹理贴图(TMP叫face)也需要4个Float,同时希望少改点TMP代码,通过Vertex, Normal, Tangent, Color, uv0, uv1把所需数据全传过去,就会发现不够用了,必须得把2个Float Pack到一个Float里,Float是7位有效数字,表示UV或字符像素宽高是足够的,我的做法是sdfUvScale转为Texel宽高Pack进tangent.z,face uv scale只Pack UV宽高到tangent.w,在VS进行Unpack。另外uv1.x是被TMP pack的UV,uv1.y是TMP用的Scale,我还没用到,为了兼容性留着。

在VS中要做的事情有:

把顶点转换到字符局部坐标系,存在cuboidLocalPos里,经过光栅化插值后成为RayMarching的起点。

unpack posBLAndUvFactor,算出sdfUvScaleOffset ,给定字符局部坐标p,可以得到sdfUV = p.xy * sdfUvScaleOffset.xy + sdfUvScaleOffset.zw。FaceUV同理。

计算视线方向,PS用插值结果。这里遇到一个问题是想同时支持透视和正交相机,Unity并没有提供这样的函数,UnityCG.cginc里的UnityWorldSpaceViewDir只能算透视viewDir。我觉得应该顶点局部坐标在Clip Space下在近平面的投影转回局部坐标系做差值算方向,但是发现又没有InvMVP矩阵,现算Inverse矩阵感觉有点费,索性就把2个方向都算出来在用01插值吧,如果有好的方法请您告诉我。

VS还是比较简单的,一个字符8个顶点,计算量也不大,PS里的重复计算可以移动到VS里来算。

1.5 PS里算RayMarching

根据上面提到的算法写RayMarching即可,input.cuboidLocalPos已经是字符局部坐标系下的点了,字符Cube的左下角在局部坐标系的原点。这里对于文字的正反面,在第一次采样SDF后就能Return,不会进行多次for。对于侧面先走步长再Clip,尽量提前剔除掉,对于视角比较接近正面,厚度不大的情况下只用执行少量几次。_outlineEpsilon是用于微调到达轮廓的条件,_outline表示边界的SDF值,默认0.5。最后用执行for循环的次数给文字上灰度色,颜色越深用的循环次数越多。

_loopCount为10,_outlineEpsilon为0的情况

_loopCount为10,_outlineEpsilon为0.01的情况

调一下_outlineEpsilon立马好很多,黑色部分都是用完for循环也没到达边缘,都是视角跟切线比较接近的情况。

这里有一个性能相关的问题没想清楚,在PS里执行分支提前Return、Clip,在GPU执行的时候并不是每个像素的PS执行独立的分支,而是一组一起执行,如果condition相同那就不用执行另一个分支了,如果condition不同就得把if else都执行了,甚至空等某个PS的执行。这里的组是2x2的像素,还是GPU实现的Warp什么的?对我写分支该如何取舍,比如是把A、B都算出来用01 lerp进行取舍好,还是用if else好呢?

_loopCount为20,_outlineEpsilon为0.01的情况

正交反面加厚不加大_loopCount为20,_outlineEpsilon为0.01的情况。左下角好像儿时见过的铝合金门窗

现在最简单的体积字就算完了,再基于局部z值上个色。

_loopCount为10,_outlineEpsilon为0.01的情况

二、字体法线

要有光照计算的话必须得有法线,正反面法线很简单就是负Z和正Z,侧面法线就是SDF值的下降梯度。

z方向没变化为0,只算xy方向SDF值的梯度即可,_SideNormalSampleDelta用于微调计算梯度的Delta。

带法线的BlinnPhong光照,正面和侧面交接处有锯齿

2.1 字形法线的圆角过渡

正面和侧面交界处法线是不连续的,所以光照下有锯齿,那么对法线做一个圆角过渡应该就可以了,用下图做圆角的分析。

俯视图,X是向轮廓内部

俯视图,OCD是正面,OBA是侧面,O点是交界处,字形轮廓的点都在ox和oz轴上,还没做字形的圆角过渡。现在希望进行半径R的法线过渡。因为已经有了正面和侧面法线,算出一个权重进行过渡比较好,假设要算B点的法线,B的坐标(0.5R,R)表示Z方向上距离过渡边界A点是0.5R,轮廓方向上距离过渡边界的D点的距离是R,AD和BE的焦点是G,理论上用DG/AD做侧面法线的权重最好,但是要算各种三角函数,或者泰勒级数展开取前几项,麻烦又费计算。用BED的夹角做权重,看似挺好,其实不对,EA向量和ED向量的线性插值结果的终点都在AD线段上,角度线性变化对应DG长度并不是线性变化的,在两头变化快,在中间变化慢,这是被否定的方法。

最简单直观,也是我一开是就想到的是用B.y / (B.x + B.y)来做权重,效果如下:

发现过渡的效果,红框内已经没有锯齿了

远看效果还凑合,近看会看到过渡不自然,如下图左侧,是因为B.y / (B.x + B.y)的过渡也不是线性的,同样两边变化快,中间变化慢。比如(0,1)到(0.1,1)变化0.1/1.1≈0.091, (0.9,1)到(1,1)变化0.026。简单的方法是对B求平方之后在算权重,虽然仍然不是线性变化,但是两头变化慢,中间变化快,效果还是很不错的,见下图右侧。

发现过渡对比

从左到右加大圆角,出现瑕疵

圆角设置太大会出现瑕疵,一是因为Padding不够大,而是因为2倍半径超过文字笔触宽度。采用大Padding的英文会好一些。

三、表面纹理

前后面的表面纹理很简单,跟采样SDF一样,就是多了个tiling&offset。

侧面要加纹理得用Triplanar Mapping方式,上下面用localPox.xz做uv采样,左右面用localPos.yz,最后用侧面法线做权重。

triplanar diffuse mapping

6面纹理看起来还算不错,但是在正面和侧面边界有很硬的过渡,因为形状上还没做圆角过渡。

四、形状的圆角过渡

IQ大神的这篇文章里有各种形状的SDF公式:

https://iquilezles.org/www/articles/distfunctions/distfunctions.htm

其中:

算出正数表示在外部,在b表示的长方体边界向外扩展,在8个角会扩张为球面,这个公式的意思是说把p中在b外部的分量拿出来求到b边界的距离,大于r算外部。

对于体积字来说没法向外扩展,只能把边界往里算一些,扩张到当前边界。现在_outline的意思就相当于上面公式的b,并且有模型半径R = _outline * bound.w, bound.w是把SDF值转为模型距离的系数。看下图考虑A、B、C三点对应上面公式中abs(p)-b,是什么

A点都在两条边界外侧,A = (R, R - z) = (R, z到中间Z的距离-R到中间Z的距离)=

(R, abs(z - halfZ) - (halfZ - R)),这是为了支持反面圆角的情况。

B在x方向属于外侧,在z方向属于内侧。B = (R, R - z) = (R, abs(z - halfZ) - (halfZ - R))

C在x方向属于内侧,在z方向属于外侧,C = (-C.x到outline的距离, R) = ((_outline - sdfValue) * bound.w, R)

D都属于外侧,D = ((_outline - sdfValue) * bound.w, R)

俯视图

此时viewDir不用除以xy屏幕投影长度,距离直接当步长。新的raymarching如下:

此时采样diffuse贴图就没有硬边了,下图右边是圆角过渡的:

五、法线纹理

法线纹理的数据是在切线空间下的,需要转换到字符局部空间。想了解切线空间和法线可以看这篇文章:

https://catlikecoding.com/unity/tutorials/rendering/part-6

对于文字正面在局部坐标系中,法线N是(0, 0, -1),切线T用正X方向(1, 0, 0) 也是uv.x的方向, binormal B用正Y方向(0, 1, 0)也是uv.y的方向,切线空间的法线X转换到局部坐标系就是:

对于侧面的情况,因为用localPos.yz做uv,所以N=(1, 0, 0), T= (0, 1, 0), B=(0, 0, 1);转换到局部坐标系:

对于上下面用xz做uv,同理:

用跟Diffuse一样的方式,用模型的sideNormal混合上下和左右的纹理法线,再跟前后面做混合,得到看似正确的结果,这种混合似乎跟Ground Truth还有差距,目前先这样。效果如下:

六、其他问题

1、抗锯齿:由于MSAA是基于光栅化的,三角形边界并不是文字形状的边界,所以无效。基于后期的AA应该是可以的。还设想过识别文字形状周围的1像素做alpha混合来实现AA,外轮廓效果应该可以,如下图绿框中的。但就跟透明有类似的排序问题了,且文字之间的边缘原没法处理,如下图红框中的:

2、遮挡:因为像素的深度值是文字Cube的边界,并不是模型的,一旦有模型穿插到文字体内会不正确,如下图,蓝色部分可见白色cube已经很接近文字cube的前面,但是红框中文字却没被遮挡。PS里写深度、RayMarching里每步测深度、调整渲染顺序应该能解决。

3、阴影:ShadowMap仍然能适用,需要Shadow Caster的Shader也用SDF raymarching的写法的到轮廓。一个TMP文字专门的Shader Receiver可以同样实现Soft Shadow,但很难同时通用的处理多个文字的阴影。

4、字形过渡:SDF一大特性是能做一个形状到另一个形状的过渡,就是对SDF距离进行过渡,对于本例的文字来说要额外指定另一个字符的UV信息,如果文字Cube大小不一样也需要变化。

5、斜体:目前的机制没处理好斜体,如何变换到字符局部坐标系的问题。

6、下划线也不支持,还没研究TMP是怎么弄的。

也欢迎大家来积极参与U Sparkle开发者计划,简称"US",代表你和我,代表UWA和开发者在一起!

  • 发表于:
  • 原文链接:https://kuaibao.qq.com/s/20180829A1PYM900?refer=cp_1026
  • 腾讯「云+社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。

扫码关注云+社区

领取腾讯云代金券