基于TMP、SDF、光线步进的体积字渲染

体积字

为了方便美术去修改场景中的有体积字,在TMP基础上,用光线步进加SDF去渲染体积字。

体积渲染

先贴参考

Mesh

直接改原版的TMP有点麻烦,退而求其次,在TMP构造好Mesh后,修改这个Mesh,以适应新的Shader。

原本TMP构造出的网格是一个个面片,每个字符由两个三角形拼成。

原始TMP网格

然后以这个mesh为基础,再添加4个点,5个面,纵向拉伸成一个个立方体.

拉伸后网格

新网格中除了保存之前的网格中的信息外,还要额外添加几个。分别是

  • 每个字符左下角顶点的坐标
  • 每个字符右上角顶点坐标
  • 每个字符左下角顶点的UV

除了这些,整体的厚度也写入材质中去。

public void SetMesh()
{
    var mesh = textMeshPro.mesh;
    var newMesh = new Mesh();
    newMesh.name = "km";

    var vertices = new Vector3[mesh.vertices.Length * 2];
    for (var i = 0; i < mesh.vertices.Length; i++)
    {
        vertices[i] = mesh.vertices[i];
        vertices[i + mesh.vertices.Length] = mesh.vertices[i] + new Vector3(0, 0, width);
    }

    newMesh.vertices = vertices;


    var sum = mesh.triangles.Length / (2 * 3);
    
    var triangles = new int[mesh.triangles.Length * 6];
    
    for (var i = 0; i < sum; i++)
    {
        var s = i * 6;
        // 前面
        triangles[s + 0] = mesh.triangles[s + 0];
        triangles[s + 1] = mesh.triangles[s + 1];
        triangles[s + 2] = mesh.triangles[s + 2];

        triangles[s + 3] = mesh.triangles[s + 3];
        triangles[s + 4] = mesh.triangles[s + 4];
        triangles[s + 5] = mesh.triangles[s + 5];

        // 后面
        triangles[s + 0 + mesh.triangles.Length] = mesh.triangles[s + 4] + mesh.vertices.Length;
        triangles[s + 1 + mesh.triangles.Length] = mesh.triangles[s + 3] + mesh.vertices.Length;
        triangles[s + 2 + mesh.triangles.Length] = mesh.triangles[s + 1] + mesh.vertices.Length;

        triangles[s + 3 + mesh.triangles.Length] = mesh.triangles[s + 1] + mesh.vertices.Length;
        triangles[s + 4 + mesh.triangles.Length] = mesh.triangles[s + 0] + mesh.vertices.Length;
        triangles[s + 5 + mesh.triangles.Length] = mesh.triangles[s + 4] + mesh.vertices.Length;

        // 右面
        triangles[s + 0 + mesh.triangles.Length * 2] = mesh.triangles[s + 0] + mesh.vertices.Length;
        triangles[s + 1 + mesh.triangles.Length * 2] = mesh.triangles[s + 1] + mesh.vertices.Length;
        triangles[s + 2 + mesh.triangles.Length * 2] = mesh.triangles[s + 1];

        triangles[s + 3 + mesh.triangles.Length * 2] = mesh.triangles[s + 1];
        triangles[s + 4 + mesh.triangles.Length * 2] = mesh.triangles[s + 0];
        triangles[s + 5 + mesh.triangles.Length * 2] = mesh.triangles[s + 0] + mesh.vertices.Length;

        // 下面
        triangles[s + 0 + mesh.triangles.Length * 3] = mesh.triangles[s + 5] + mesh.vertices.Length;
        triangles[s + 1 + mesh.triangles.Length * 3] = mesh.triangles[s + 5];
        triangles[s + 2 + mesh.triangles.Length * 3] = mesh.triangles[s + 4];

        triangles[s + 3 + mesh.triangles.Length * 3] = mesh.triangles[s + 4];
        triangles[s + 4 + mesh.triangles.Length * 3] = mesh.triangles[s + 4] + mesh.vertices.Length;
        triangles[s + 5 + mesh.triangles.Length * 3] = mesh.triangles[s + 5] + mesh.vertices.Length;

        // 左面
        triangles[s + 0 + mesh.triangles.Length * 4] = mesh.triangles[s + 4];
        triangles[s + 1 + mesh.triangles.Length * 4] = mesh.triangles[s + 3];
        triangles[s + 2 + mesh.triangles.Length * 4] = mesh.triangles[s + 3] + mesh.vertices.Length;

        triangles[s + 3 + mesh.triangles.Length * 4] = mesh.triangles[s + 3] + mesh.vertices.Length;
        triangles[s + 4 + mesh.triangles.Length * 4] = mesh.triangles[s + 4] + mesh.vertices.Length;
        triangles[s + 5 + mesh.triangles.Length * 4] = mesh.triangles[s + 4];

        // 上面
        triangles[s + 0 + mesh.triangles.Length * 5] = mesh.triangles[s + 1];
        triangles[s + 1 + mesh.triangles.Length * 5] = mesh.triangles[s + 1] + mesh.vertices.Length;
        triangles[s + 2 + mesh.triangles.Length * 5] = mesh.triangles[s + 2] + mesh.vertices.Length;

        triangles[s + 3 + mesh.triangles.Length * 5] = mesh.triangles[s + 2] + mesh.vertices.Length;
        triangles[s + 4 + mesh.triangles.Length * 5] = mesh.triangles[s + 2];
        triangles[s + 5 + mesh.triangles.Length * 5] = mesh.triangles[s + 1];
    }

    newMesh.triangles = triangles;


    var normals = new Vector3[mesh.normals.Length * 2];
    for (var i = 0; i < mesh.normals.Length; i++)
    {
        normals[i] = mesh.normals[i];
        normals[i + mesh.normals.Length] = mesh.normals[i];
    }

    newMesh.normals = normals;

    var tangents = new Vector4[mesh.tangents.Length * 2];

    for (var i = 0; i < mesh.tangents.Length; i++)
    {
        tangents[i] = mesh.tangents[i];
        tangents[i + mesh.tangents.Length] = mesh.tangents[i];
    }

    newMesh.triangles = triangles;

    var colors32 = new Color32[mesh.colors.Length * 2];

    for (var i = 0; i < mesh.colors32.Length; i++)
    {
        colors32[i] = mesh.colors32[i];
        colors32[i + mesh.colors32.Length] = mesh.colors32[i];
    }

    newMesh.colors32 = colors32;

    var uv = new Vector2[mesh.uv.Length * 2];

    for (var i = 0; i < mesh.uv.Length; i++)
    {
        uv[i] = mesh.uv[i];
        uv[i + mesh.uv.Length] = mesh.uv[i];
    }

    newMesh.uv = uv;


    var sum2 = newMesh.vertices.Length / (4);
    Debug.Log(sum2);

    var uv3 = new Vector2[mesh.uv.Length * 2];
    var uv4 = new Vector2[mesh.uv.Length * 2];
    var uv5 = new Vector2[mesh.uv.Length * 2];

    for (int i = 0; i < sum2; i++)
    {
        var s = i * 4;
        // 左下角顶点坐标
        Vector2 v = newMesh.vertices[s + 0];
        uv3[s + 0] = v;
        uv3[s + 1] = v;
        uv3[s + 2] = v;
        uv3[s + 3] = v;

        Vector2 tr = newMesh.vertices[s + 2];
        uv5[s + 0] = tr;
        uv5[s + 1] = tr;
        uv5[s + 2] = tr;
        uv5[s + 3] = tr;

        Vector2 blUV = uv[s];

        uv4[s + 0] = blUV;
        uv4[s + 1] = blUV;
        uv4[s + 2] = blUV;
        uv4[s + 3] = blUV;
    }

    newMesh.uv3 = uv3;
    newMesh.uv4 = uv4;
    newMesh.uv5 = uv5;

    var uv2 = new Vector2[mesh.uv2.Length * 2];

    for (var i = 0; i < mesh.uv2.Length; i++)
    {
        uv2[i] = mesh.uv2[i];
        uv2[i + mesh.uv2.Length] = mesh.uv2[i];
    }

    newMesh.uv2 = uv2;

    meshFilter.mesh = newMesh;

    material.SetFloat(Width, width);
}

Shader

网格准备好就轮到Shader了。

顶点着色器

这里没什么特殊的,只要把相关的顶点属性传递到片元中就行了。

pixel_t VertShader(vertex_t input)
{
    pixel_t output;

    UNITY_SETUP_INSTANCE_ID(input);
    UNITY_TRANSFER_INSTANCE_ID(input, output);
    UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);

    // 粗体
    float bold = step(input.texcoord0.w, 0);

    // 顶点偏移
    float4 vert = input.position;
    float4 vPosition = TransformObjectToHClip(vert.xyz);

    float2 pixelSize = vPosition.w;
    pixelSize /= float2(_ScaleX, _ScaleY) * abs(mul((float2x2)UNITY_MATRIX_P, _ScreenParams.xy));
    float scale = rsqrt(dot(pixelSize, pixelSize));
    scale *= abs(input.texcoord0.w) * _GradientScale * (_Sharpness + 1);

    if (UNITY_MATRIX_P[3][3] == 0)
        scale = lerp(abs(scale) * (1 - _PerspectiveFilter), scale,
                     abs(dot(TransformObjectToWorldNormal(input.normal.xyz),
                             normalize(GetWorldSpaceViewDir(vert)))));

    float weight = lerp(_WeightNormal, _WeightBold, bold) / 4.0;
    weight = (weight + _FaceDilate) * _ScaleRatioA * 0.5;

    float bias = (.5 - weight) + (.5 / scale);

    float alphaClip = (1.0 - _OutlineWidth * _ScaleRatioA - _OutlineSoftness * _ScaleRatioA);


    alphaClip = alphaClip / 2.0 - (.5 / scale) - weight;

    // Support for texture tiling and offset
    float2 textureUV = input.texcoord1;
    float2 faceUV = TRANSFORM_TEX(textureUV, _FaceTex);


    output.positionCS = vPosition;
    output.color = input.color;
    // 字符图集上的UV
    output.atlas = input.texcoord0;

    output.param = float4(alphaClip, scale, bias, weight);

    output.viewDir = mul((float3x3)_EnvMatrix,
                         _WorldSpaceCameraPos.xyz - mul(unity_ObjectToWorld, vert).xyz);

    output.textures = float4(faceUV, 0, 0);

    output.positionWS = TransformObjectToWorld(vert.xyz).xyz;

    output.bltr = float4(input.texcoord2.xy, input.texcoord4.xy);
    output.bluv = input.texcoord3;

    return output;
}

光线步进

然后就开始光线步进吧。
从片元的表面出发,沿视线方向,以步进到的位置采样SDF图。
如果超出了当前字符方块的范围,就Clip掉。否则就一直步进,直至进入字符SDF内部或者迭代次数耗尽。

float3 posWS = input.positionWS.xyz;
float3 posBLOS = float3(input.bltr.xy, 0);
float3 posTROS = float3(input.bltr.zw, _width);

float2 blUV = input.bluv.xy;

float3 normal;

float3 currentPos = posWS;
float3 currentPosOS = TransformWorldToObject(currentPos);;
float3 viewDir = -normalize(input.viewDir);

float dis = GetDisFromPos(currentPosOS, posBLOS, blUV);
if (dis < 0)
{
    if (currentPosOS.z < 0.01)
        normal = float3(0, 0, -1);
    else
        normal = float3(0, 0, 1);
}
else
{
    for (int i = 0; i < 20; i++)
    {
        if (!isIn(posBLOS, posTROS, currentPosOS))
        {
            clip(-1);
        }

        dis = GetDisFromPos(currentPosOS, posBLOS, blUV);
        if (dis < 0)
        {
            break;
        }
        currentPos += viewDir * dis * 0.3;
        currentPosOS = TransformWorldToObject(currentPos);;
    }
    normal = float3(normalize(GetNormalFromUV(GetUVFromPos(currentPosOS, posBLOS, blUV))), 0);
}

第一次迭代中如果是前后表面就不需要步进了。

基础形状

法线

前后面法线很简单,侧面法线就用SDF图的梯度来计算


float2 GetNormalFromUV(float2 uv)
{
    float2 normal = float2(0, 0);
    float2 uv1 = uv + float2(0, 1) / 512;
    float2 uv2 = uv + float2(0, -1) / 512;

    float2 uv3 = uv + float2(1, 0) / 512;
    float2 uv4 = uv + float2(-1, 0) / 512;
    float4 c1 = tex2D(_MainTex, uv1);
    float4 c2 = tex2D(_MainTex, uv2);
    float4 c3 = tex2D(_MainTex, uv3);
    float4 c4 = tex2D(_MainTex, uv4);
    normal.y = c2.a - c1.a;
    normal.x = c4.a - c3.a;
    return normal;
}

法线

深度偏移

要通过SV_Depth来设定偏移后的深度

float4 PixShader(pixel_t input, out float outDepth : SV_Depth) : SV_Target


    float deviceDepth = ComputeNormalizedDeviceCoordinatesWithZ(currentPos, GetWorldToHClipMatrix()).z;
    #ifndef  UNITY_UV_STARTS_AT_TOP
    deviceDepth = (deviceDepth +1 )/2;
    #endif
    outDepth = deviceDepth;

这里有个大坑,在OpenGL下,深度要从-1,1映射到0,1

深度偏移

着色

这里就用LIT的PBR来着色
UV先简单用物体空间的XY。

PBR着色

float2 uv = currentPos.xy * 1;

SurfaceData surfaceData;
InitializeStandardLitSurfaceData(uv, surfaceData);

InputData inputData;
float3 normalWS = TransformObjectToWorldNormal(normal);
input.positionWS = currentPos;
InitializeInputData(input, normalWS, inputData);

half4 color = UniversalFragmentPBR(inputData, surfaceData);

return color;

三向映射

侧面也需要UV,所以用法线作为权重,每个轴的坐标作为UV采样三次后混合

三向映射


half3 blendWeights = pow (abs(normal), 3);
blendWeights = blendWeights / (blendWeights.x + blendWeights.y + blendWeights.z);

half2 xUV = pos.zy / 1;
half2 yUV = pos.xz / 1;
half2 zUV = pos.xy / 1;

half4 albedoAlphaX = SampleAlbedoAlpha(xUV, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap));
half4 albedoAlphaY = SampleAlbedoAlpha(yUV, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap));
half4 albedoAlphaZ = SampleAlbedoAlpha(zUV, TEXTURE2D_ARGS(_BaseMap, sampler_BaseMap));

阴影

增加一个ShadowCaster Pass来投射阴影。

三向映射

Pass
{
    Name "ShadowCaster"
    Tags
    {
        "LightMode" = "ShadowCaster"
    }

    ZWrite On
    ZTest LEqual
    ColorMask 0


    HLSLPROGRAM
    #pragma target 3.0

    #pragma vertex VertShader
    #pragma fragment PixShadowCaster

    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/Shaders/LitInput.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Shadows.hlsl"

    #include "TMPro_Properties.hlsl"
    #include "SDF.hlsl"
    ENDHLSL
}

这里的步进方向记得改为从主光源的方向来步进,同时不要忘记应用阴影偏移。
同样最后要用SV_Depth来偏移深度。


float3 viewDir = -normalize(_LightDirection);


currentPos = ApplyShadowBias(currentPos, normal, _LightDirection);

也别忘记在前向Pass中增加接收阴影的宏

        #pragma multi_compile _ _MAIN_LIGHT_SHADOWS _MAIN_LIGHT_SHADOWS_CASCADE _MAIN_LIGHT_SHADOWS_SCREEN

屏幕空间AO

有了上面的代码,增加一个DepthNormals Pass就可以给SSAO提供法线和深度了。
这里参数调整的很大以看出区别。
SSAO

SSAO

抗锯齿

因为没有网格,所以MSAA肯定是不行。直接就用URP的TAA了,效果还可以。
或者就添加圆角。

倒角

有点麻烦
先从倒角开始,倒一个面。基本思路就是在计算SDF时,在需要倒角切除的部分,手动增加一个距离。

float GetDisFromPos(float3 posOS, float3 posBLWS, float2 blUV)
{
    float2 UV = GetUVFromPos(posOS, posBLWS, blUV);
    float dis = 1 - tex2D(_MainTex, UV).a * 2;
    
    if(dis < -0.1 )
    {
        return 0;
    }
    
    float v =  abs(((posOS.z / _width) - 0.5) * 2);
    v = (clamp( v, 0.9, 1) - 0.9)*10;
    dis += v * 0.1;
    return dis;
}

然后是法线,这就简单了,lerp一下就行了

float3 BlenderNormal(float3 normal,float3 posOS)
{
    float v =  abs(((posOS.z / _width) - 0.5) * 2);
    v = step(0.9, v);
    float vv = sign(((posOS.z / _width) - 0.5) );
    float3 nn = normalize(float3(0,0,vv));
    float3 fN = normalize(nn + normal);
    return lerp(normal,fN,v);
}

倒角

圆角

然后是圆角。
参考这篇文章

关键是其中这个圆角矩形的SDF

圆角

改进

  • 可以仿照TMP,增加对应的外轮廓,下划线,斜体等等
  • UV贴图也可以改进,分成侧面和正反面两种UV,适配两种不同的材质。
  • 法线的获取,目前是在Shader中采样四个点去计算,可以预先根据SDF图去生成对应的法线图,直接采样。

局限

因为没有常规的UV,所以没有办法烘焙光照,也不能给其他静态物体提供静态的光照。
但既然要用体积字,应该都是有运行时更改且动态的需求,所以一般不会用来烘焙。
如果需要那种字体侧面发光的效果,可以在后面放一个TMP,做成透明渐变来模拟泛光。

摄像机不能进入字符立方体内部,否则就要渲染反面且从近平面开始步进,得不偿失。


基于TMP、SDF、光线步进的体积字渲染
https://www.kuanmi.top/2023/07/26/TMP/
作者
KuanMi
发布于
2023年7月26日
许可协议