基于2D SDF的体积字实现

基于2D SDF的体积字实现

在VR应用中,对于体积字展现的需求越来越普遍,本文笔者基于TMP的基础原理,通过对形状计算、圆角过渡、表面纹理、形状法线和法线纹理分别进行详述,说明了体积字的实现方法,值得参考。


概述

笔者很久之前就想做体积字,并认为在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

Ray Marching and 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插值吧,如果有好的方法请您告诉我。

// UnityCG.cginc里的view dir相关方法
// Computes world space view direction, from object space position
inline float3 UnityWorldSpaceViewDir( in float3 worldPos )
{
    return _WorldSpaceCameraPos.xyz - worldPos;
}

// Computes world space view direction, from object space position
// *Legacy* Please use UnityWorldSpaceViewDir instead
inline float3 WorldSpaceViewDir( in float4 localPos )
{
    float3 worldPos = mul(unity_ObjectToWorld, localPos).xyz;
    return UnityWorldSpaceViewDir(worldPos);
}

// Computes object space view direction
inline float3 ObjSpaceViewDir( in float4 v )
{
    float3 objSpaceCameraPos = mul(unity_WorldToObject, float4(_WorldSpaceCameraPos.xyz, 1)).xyz;
    return objSpaceCameraPos - v.xyz;
}

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

// vs的输入
struct appdata
{
    float4 vertex       : POSITION;
    float3 bound        : NORMAL;   // 字符cube的长宽高
    float4 posBLAndUvFactor     : TANGENT;  // xy是字符cube左下点的局部坐标,zw是sdf和face的uv转换系数被pack的值
    fixed4 color        : COLOR;
    float2 uv0              : TEXCOORD0;    // uvBL
    float2 uv1              : TEXCOORD1;    // uv2BL
};

// ps的输入
struct v2f
{
    float4 vertex                : SV_POSITION;
    fixed4 faceColor             : COLOR;
    float4 sdfUvScaleOffset      : TEXCOORD0;   //转换mesh局部坐标到sdf uv,xy是系数,zw是常量
    float4 faceUvScaleOffset     : TEXCOORD1;   //转换mesh局部坐标到face uv,xy是系数,zw是常量
    float3 viewDir               : TEXCOORD2;   //
    float3 cuboidLocalPos        : TEXCOORD3;   //xyz是位置,左下角在原点。
    float4 bound                 : TEXCOORD4;   // xyz是模型size, w是转换sdf距离到模型距离的系数
    float3 localLightPos         : TEXCOORD5;   //字符局部坐标系下光的位置,表面到光源方向
    float4 volParam              : TEXCOORD6;   //x是模型空间下的轮廓圆角半径
};          

// vertex shader
v2f Vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.faceColor = v.color;

    // 左下角平移到局部坐标系原点,此时坐标范围是[0, bound]
    o.cuboidLocalPos = v.vertex - float4(v.posBLAndUvFactor.xy, 0, 0);

    float2 inverseBound = 1 / v.bound.xy;
    float2 sdfUvScale = UnpackFloat(v.posBLAndUvFactor.z, 1) * inverseBound / float2(_TextureWidth, _TextureHeight);
    o.sdfUvScaleOffset = float4(sdfUvScale, v.uv0);

    float2 faceUvScale = UnpackFloat(v.posBLAndUvFactor.w, 512) * inverseBound;
    o.faceUvScaleOffset = float4(faceUvScale, UnpackFloat(v.uv1.x, 512));

    // 视线转换到模型局部坐标系,此时是整个模型(很多字符)的局部坐标系,到ps里再normalize
    // _WorldSpaceCameraPos是float3,补上w=1才能正确转换
    // 没找到更好的计算透视和正交的方法,clip坐标系下载近平面的投影转换到localspace比较好,但没看到InvMVP矩阵
    float3 perspectiveViewDir = v.vertex - mul(unity_WorldToObject, float4(_WorldSpaceCameraPos.xyz, 1));
    float3 orthographicViewDir = mul(unity_WorldToObject, mul(unity_MatrixInvV, float4(0, 0, -1, 0)));
    o.viewDir = lerp(perspectiveViewDir, orthographicViewDir, unity_OrthoParams.w);

    // sdf距离到模型距离的系数,sdf的0.5对应字体的_GradientScale个图素,对应的uv范围,再到对应的模型距离
    // xy方向上可能是不一致的,为了正确性取相对小的步长
    float sdf2len = 2 * _GradientScale / (max(_TextureWidth * sdfUvScale.x, _TextureHeight * sdfUvScale.y)) * _stepLenFactor;
    o.bound = float4(v.bound, sdf2len);

    o.localLightPos = mul(unity_WorldToObject, _WorldSpaceLightPos0);

    o.volParam = float4(v.bound.x * _outlineRadius, 0, 0, 0);
    return o;

1.5 PS里算RayMarching

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

loat SampleSdfValueByLocalPos(v2f input, float3 localPos)
{
    float2 sdfUv = localPos.xy * input.sdfUvScaleOffset.xy + input.sdfUvScaleOffset.zw;
    // 编译警告说for里得用lod版本的tex2D
    return tex2Dlod(_MainTex, float4(sdfUv, 0, 0)).a;
}

float2 RayMarching(v2f input, out float3 localPos, float3 viewDir)
{
    localPos = input.cuboidLocalPos.xyz;

    // 除以水平距离,这样在乘以水平距离就知道3d方向的增量
    viewDir /= max(length(viewDir.xy), 0.001);

    int i = 0;
    float sdfValue;
    for (; i < _loopCount; i++)
    {
        sdfValue = SampleSdfValueByLocalPos(input, localPos);
        float sdfDist = _outline - sdfValue;
        float meshDist;

        if (sdfDist <= _outlineEpsilon)
            return float2(i, sdfValue);

        meshDist = sdfDist * input.bound.w;

        // sdf距离转到模型距离,向前march
        localPos += viewDir * meshDist;

        // 到达模型外面要被剔除掉
        // 剔除并不需要epsilon解决精度问题比较欣慰
        clip(localPos * (input.bound.xyz - localPos));
    }

    // 没有到达范围内,但应该也很接近了
    sdfValue = SampleSdfValueByLocalPos(input, localPos);
    return float2(i, sdfValue);
}

// ps
fixed4 Frag (v2f input) : SV_Target
{
    float3 localPos;
    float3 viewDir = normalize(input.viewDir);
    float2 result = RayMarching(input, localPos, viewDir);
    fixed4 col = 1 - result.x / _loopCount;
    col.a = 1;
    return col;
}

请输入图片描述
_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。

// 计算法线,前后面法线通过z计算,4个侧面法线是sdf的下降梯度
void ComputeNormal(v2f input, float3 localPos, out float3 frontBackNormal, out float3 sideNormal)
{
    // 前或后面的法线,不是侧面的. 圆角过渡部分也需要正反面法线
    frontBackNormal = float3(0, 0, localPos.z > input.bound.z - localPos.z ? 1 : -1);

    // 用小位置差算梯度,得在中心点周围的4个点采样,只采样2个是不对的
    float2 deltaPos = input.bound.xy * _SideNormalSampleDelta;
    float sdfDeltaX1 = SampleSdfValueByLocalPos(input, localPos + float3(deltaPos.x, 0, 0));
    float sdfDeltaX0 = SampleSdfValueByLocalPos(input, localPos - float3(deltaPos.x, 0, 0));
    float sdfDeltaY1 = SampleSdfValueByLocalPos(input, localPos + float3(0, deltaPos.y, 0));
    float sdfDeltaY0 = SampleSdfValueByLocalPos(input, localPos - float3(0, deltaPos.y, 0));
    // 避免除以0,不然所在像素颜色未定义
    sideNormal = Unity_SafeNormalize(float3(sdfDeltaX0 - sdfDeltaX1, sdfDeltaY0 - sdfDeltaY1, 0));
}

// z很接近前后面后frontBackNormal,否则用sideNormal,光照部分就不贴出来了

请输入图片描述
带法线的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求平方之后在算权重,虽然仍然不是线性变化,但是两头变化慢,中间变化快,效果还是很不错的,见下图右侧。

请输入图片描述
发现过渡对比

// 计算圆角过渡中侧面的权重,input.volParam.x是半径
float ComputeSideWeight(v2f input, float3 localPos, float sdfValue)
{
    // sdf中到边界的距离,转换为模型的长度
    float toOutlineDist = (sdfValue - _outline) * input.bound.w;
    float outlineFactor = max(input.volParam.x - toOutlineDist, 0);
    float depthFactor = max(input.volParam.x - min(localPos.z, input.bound.z - localPos.z), 0);

    // 修改斜率,使圆角边缘平缓,中间陡一点
    outlineFactor *= outlineFactor;
    depthFactor *= depthFactor;
    // 对side和frontback做过渡
    float sideWeight = outlineFactor / (outlineFactor + depthFactor);
    return sideWeight;
}

请输入图片描述
从左到右加大圆角,出现瑕疵

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

三、表面纹理

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

// 采样face tex,可以是diffuse、normal、specular等
float4 SampleFaceTexByLocalPos(v2f input, float3 localPos, sampler2D tex)
{
    float2 uv = localPos.xy * input.faceUvScaleOffset.xy + input.faceUvScaleOffset.zw;
    // 所有face相关的tex都用这个tiling offset
    uv = uv * _FaceDiffuseTex_ST.xy + _FaceDiffuseTex_ST.zw;
    return tex2D(tex, uv);
}

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

float4 SampleSideDiffuse(float3 localPos, float3 sideNormal)
{
    // 左右面
    float2 uvLR = localPos.yz * _LeftRightDiffuse_ST.xy + _LeftRightDiffuse_ST.zw;
    float4 diffuseLR = tex2D(_LeftRightDiffuse, uvLR) * _LeftRightColor;
    
    // 上下面
    float2 uvUD = localPos.xz * _UpDownDiffuse_ST.xy + _UpDownDiffuse_ST.zw;
    float4 diffuseUD = tex2D(_UpDownDiffuse, uvUD) * _UpDownColor;

    sideNormal = abs(sideNormal);
    // 非等比混合,UD会占得多
    //return lerp(diffuseLR, diffuseUD, sideNormal.y);
    // 等比混合
    return lerp(diffuseLR, diffuseUD, sideNormal.y / (sideNormal.x + sideNormal.y));
}

请输入图片描述
triplanar diffuse mapping

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

四、形状的圆角过渡

IQ大神的这篇文章里有各种形状的SDF公式:
https://iquilezles.org/www/articles/distfunctions/distfunctions.html

其中:

Round Box - unsigned - exact
float udRoundBox( vec3 p, vec3 b, float r )
{
  return length(max(abs(p)-b,0.0))-r;
}

请输入图片描述

算出正数表示在外部,在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)

请输入图片描述
俯视图

// xz分量相对内退边界的距离,正表示在外,负表示在内
float2 meshOutlineDist = ((_outline - sdfValue) * input.bound.w + R, abs(localPos.z - halfZ)-(halfZ - R));
// length(max(meshOutlineDist, 0)) - R;就是对应上面udRoundBox公式求的值,表示3D空间下到形状的最近距离

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

// 修改之前的检测,使支持形状的圆角过渡
float2 RayMarching(v2f input, out float3 localPos, float3 viewDir)
{
    localPos = input.cuboidLocalPos.xyz;

    // 用于剔除模型外顶点时微调
    //float3 epsilon = input.bound.xyz * _boundEpsilon;
#if SDFRoundEdge 
    float halfZ = input.bound.z * 0.5;
#else
    // 除以水平距离,这样在乘以水平距离就知道3d方向的增量
    viewDir /= max(length(viewDir.xy), 0.001);
#endif

    int i = 0;
    float sdfValue;
    for (; i < _loopCount; i++)
    {
        sdfValue = SampleSdfValueByLocalPos(input, localPos);
        float sdfDist = _outline - sdfValue;
        float meshDist;

    #if SDFRoundEdge 
        // Round Box formula - unsigned - exact
        //float udRoundBox(vec3 p, vec3 b, float r)
        //{
        //  return length(max(abs(p) - b, 0.0)) - r;
        //}
        float radius = input.volParam.x;
        float2 meshOutlineDist = float2(sdfDist * input.bound.w + radius, abs(localPos.z - halfZ) - (halfZ - radius));
        meshDist = length(max(meshOutlineDist, 0)) - radius;
        // 足够靠近边界就认为到达
        if (meshDist <= 0)
            return float2(i, sdfValue);
    #else
        // 足够靠近边界就认为到达
        if (sdfDist <= _outlineEpsilon)
            return float2(i, sdfValue);

        meshDist = sdfDist * input.bound.w;
    #endif
        // sdf距离转到模型距离,向前march
        localPos += viewDir * meshDist;

        // 到达模型外面要被剔除掉
        //clip((localPos + epsilon) * (input.bound.xyz - localPos + epsilon));
        clip(localPos * (input.bound.xyz - localPos));
    }

    // 没有到达范围内,但应该也很接近了
    sdfValue = SampleSdfValueByLocalPos(input, localPos);
    return float2(i, sdfValue);
}

此时采样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转换到局部坐标系就是:

X.x * T + X.y * B + X.z * N;
// 简化一下,并且支持反面就是
float isForward = localPos.z > input.bound.z - localPos.z ? 1 : -1;
float3 finalNormal = X.xyz * float3(1, 1, isForward);

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

X.x * T + X.y * B + X.z * N;
float isRight = sideNormal.x > 0 ? 1 : -1;
float3 leftRightNormal = X.zxy * float3(isRight, 1, 1);

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

float isUp = sideNormal.y > 0 ? 1 : -1;
float3 upDownNormal = X.xzy * float3(1, isUp, 1);

用跟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是怎么弄的。


这是侑虎科技第438篇文章,感谢作者燃野供稿。欢迎转发分享,未经作者授权请勿转载。如果您有任何独到的见解或者发现也欢迎联系我们,一起探讨。
QQ群:793972859(原群已满员)

作者主页:https://www.zhihu.com/people/yesbaba/posts
作者也是U Sparkle活动参与者,UWA欢迎更多开发朋友加入U Sparkle开发者计划,这个舞台有你更精彩!