动机

直接动机是想要在unity中制作一个描边效果。对于卡通渲染的描边效果,已经有很多很多的案例了,但是我觉得这些案例不一定能完全满足我的需求,于是想要从画直线开始研究。

从普通绘画的角度来看,很重要的一点就是描边的宽度基本上是一致的:考虑一下远景的物体,画家在绘画的时候使用和近景相同粗细的笔(用一样或者稍弱的力),绘制一个较不精细的物体,而不是使用很细的笔去绘制一个精细的物体,结论就是远处的描边可能稍细一些,但最好是相同粗细,其颜色可能会变浅。

从另一个角度来看,描边往往需要能够控制其宽度,对于较细的线,则会有较明显的锯齿(事实上只要角度不太好的描边,就会有很明显的边缘锯齿),那么控制粗细和进行一定程度的抗锯齿也是一个研究的方向。

从第三个角度,碰巧看到了Freya Holmér制作的Shapes插件,能够绘制高质量的线条画,看上去渲染的效果很好。但是价格过于高昂,于是想要研究一个能够做到差不多效果的工具。

抗锯齿和宽度的思考

在Unity中画直线有蛮多办法,Debug.DrawLine或者Gizmos.DrawLine都能绘制等宽的直线,不过只能固定一个像素宽,而且因为只有一个像素宽,所以会有明显的锯齿。使用LineRenderer可以绘制任意宽度的直线,写特定的shader通过透明度混合能够防止锯齿的出现(不过线段两端不太好做抗锯齿),但是不能保证在不同角度下直线的宽度相等。那么答案就很明显了,通过线段的顶点的数据,生成两个三角面(一个Quad),在GPU中计算出三角面每个顶点的屏幕空间的位置,确保线段的宽度一致。也由于有宽度的存在,给后续的透明度混合抗锯齿留下了操作的空间。

具体的绘制线条的操作

首先看一张图(完了三角形顶点顺序画反了,已经积重难返了,代码表现对就当做对的吧) Equal Width Line Diagram

我们将使用Graphics.DrawProcedural这个方法来绘制我们的直线,每一段直线由两个三角形组成(这里要注意在unity中三角形顶点顺序是顺时针的),也就是说要绘制N条直线的话,要传入N+1个节点位置数据,而实际绘制时会绘制6N个顶点。这样传入Shader的是一个表示线段节点位置的长度是N+1的Vector3数据_VerticesBuffer,和一个表示顶点序号的从0到6N-1的uint数据,也就是在绘制时图形API自动传输的SV_VERTEXID

这也就导致了我们的顶点着色器输入数据和普通的Shader有所区别,只有一个uint数据:

struct Attributes
{
    uint vertexID    : SV_VERTEXID;
};

在顶点着色器获取到SV_VERTEXID之后,我们可以以此来计算出线段的序号lineID和每个顶点在绘制线段时的序号vertexID。在绘制时我们又要知道当前线段的两个节点的位置,在传入_VerticesBuffer之后,我们需要对每个顶点确定其对应的线段节点的序号,即lineID + 0或者lineID + 1,我们可以将这个可以通过vertexID确定的0或者1储存到一个数组vertexIndexes中,方便使用vertexID读取。

为了让线段取得较好的抗锯齿效果,我们把线段整体外扩了_OutlineWidth个像素宽(实际线段宽度是这个的两倍)。此时对于每一个顶点,我们需要知道外扩之后的屏幕空间的顶点位置.由于我们需要使用从LineStartLineEnd的方向offset(对于1, 2, 4这几个顶点来说,offset是从LineEndLineStart的方向)和与其相垂直的方向来做外扩,我们需要把每个顶点的两个方向值储存到数组vertexOffsets中。可以看到顶点永远是往远离较远节点的方向移动的,因此vertexOffset的x值都是-1。而我们选取offset逆时针旋转90°的方向作为相垂直的方向,因此vertexOffset的y值会有正负之分。

最后是uv的数值,我们还需要给每个顶点传入uv的数值,就比较简单了,储存到数组vertexUVs中。

此外还要注意一点,在片元着色器中,我们需要知道LineStartLineEnd对应的uv位置来做线段的圆形端点,因此我们还需要把这个长方体的高宽比ratio传给片元着色器。

这样,我们片元着色器的输入数据就是positionCSuvratio了:

struct Varyings
{
    float4 positionCS   : SV_POSITION;
    float2 uv           : TEXCOORD0;
    float ratio         : TEXCOORD1;
};

绘制线条的流程

  1. C#脚本获取要绘制线条的节点数据,传入_VerticesBuffer,调用Graphics.DrawProcedural方法进行绘制。
  2. Shader的顶点着色器根据SV_VERTEXID计算出每个顶点对应的线段序号lineID和顶点序号vertexID
  3. 通过线段序号、顶点序号和vertexIndexes_VerticesBuffer中找到较近的线段节点的世界坐标,和较远的线段节点的世界坐标。
  4. 将两个世界坐标转换到屏幕空间,得到屏幕空间两点对应的向量和与之相垂直的向量,进行归一化。
  5. 将每个顶点根据vertexOffsets和描边宽度进行外扩。
  6. 将外扩后的裁剪空间的坐标,从vertexUVs中获得的uv和高宽比ratio传给片元着色器。
  7. 片元着色器根据ratiouv,绘制出一个两头圆形的线段。

DrawEqualWidthLine.cs

C#脚本比较简单了,没什么特别需要注意的地方。

using UnityEngine;

public class DrawEqualWidthLine : MonoBehaviour
{
    public Vector3[] vertices;
    private int vertexCount;

    public Material equalWidthMaterial;
    ComputeBuffer verticesBuffer;

    private void EnsureBuffer(ref ComputeBuffer buffer, int count, int stride)
    {
        if (buffer == null)
        {
            buffer = new ComputeBuffer(count, stride, ComputeBufferType.Structured);
        }
        else if (buffer.count != count || buffer.stride != stride)
        {
            buffer.Release();
            buffer = new ComputeBuffer(count, stride, ComputeBufferType.Structured);
        }
    }

    private void Awake()
    {
        vertexCount = vertices.Length;
        EnsureBuffer(ref verticesBuffer, vertexCount, 3 * 4);
    }

    private void Update()
    {
        if(vertexCount != vertices.Length)
        {
            vertexCount = vertices.Length;
            EnsureBuffer(ref verticesBuffer, vertexCount, 3 * 4);
        }

        Bounds bounds = new Bounds(Vector3.zero, Vector3.one * 100.0f);
        verticesBuffer.SetData(vertices);

        MaterialPropertyBlock mpb = new MaterialPropertyBlock();
        mpb.SetBuffer("_VerticesBuffer", verticesBuffer);

        Graphics.DrawProcedural(equalWidthMaterial, bounds, MeshTopology.Triangles, (vertexCount - 1) * 6, properties: mpb);
    }

    void OnDestroy()
    {
        verticesBuffer.Dispose();
    }
}

EqualWidthLineShader.shader

基本上需要注意的都写在shader的注释里面了,使用_OutlineColor来控制线段的颜色,_OutlineWidth来控制线段的宽度,_Sharpness来控制锐利程度以达到抗锯齿的效果。

特别要注意的就是屏幕比例的问题,要确保在屏幕中两个偏移方向是互相垂直的,这样才能获得正确的新的裁剪空间的坐标,也才能获得正确的线段节点的uv值。还有要注意仿射变换和透视变换的区别,参考维基百科上的说明,决定了uv在屏幕空间中是不是线性的。

当然三个数组可以合并成一个,不过也没那个必要就是了。

Shader "zznewclear13/EqualWidthLineShader"
{
    Properties
    {
        _OutlineColor ("Outline Color", color) = (1.0, 1.0, 1.0, 1.0)
        _OutlineWidth("Outline Width", float) = 10.0
        _Sharpness("Sharpness", range(0, 0.99)) = 0.5
    }
    HLSLINCLUDE
    #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
    #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

    StructuredBuffer<float3> _VerticesBuffer;
    float4 _OutlineColor;
    float _OutlineWidth;
    float _Sharpness;

    static int vertexIndexes[] = 
    {
        0, 1, 1, 0, 1, 0
    };

    static float2 vertexOffsets[] = 
    {
        float2(-1, -1),
        float2(-1, 1),
        float2(-1, -1),
        float2(-1, -1),
        float2(-1, -1),
        float2(-1, 1),
    };

    static float2 vertexUVs[] = 
    {
        float2(0, 0),
        float2(1, 0),
        float2(1, 1),
        float2(0, 0),
        float2(1, 1),
        float2(0, 1),
    };

    struct Attributes
    {
        uint vertexID    : SV_VERTEXID;
    };

    struct Varyings
    {
        float4 positionCS   : SV_POSITION;
        float2 uv           : TEXCOORD0;
        float ratio         : TEXCOORD1;
    };

    Varyings Vert(Attributes input)
    {
        Varyings output = (Varyings)0;
        int vertexID = input.vertexID % 6;
        int lineID = input.vertexID / 6;

        //获取较近的线段节点的世界坐标,和较远的线段节点的世界坐标
        float3 vertexPos = _VerticesBuffer[lineID + vertexIndexes[vertexID]];
        float3 anotherVert = _VerticesBuffer[lineID + 1 - vertexIndexes[vertexID]];
        //转换到裁剪空间
        float4 positionCS = mul(UNITY_MATRIX_VP, float4(vertexPos, 1.0));
        float4 anotherCS = mul(UNITY_MATRIX_VP, float4(anotherVert, 1.0));

        //Unity相机是看向Z轴负方向的,所以裁剪空间w分量小于0
        //这里就不需要用从远的节点减去近的节点了
        float2 offset = positionCS.xy / positionCS.w - anotherCS.xy / anotherCS.w;
        //得到长方形宽度
        float lengthOffset = length(offset);
        //要考虑到屏幕的比例
        float2 normalizedOffset = normalize(offset * (_ScreenParams.wz - 1.0));
        float2 pointOffset = vertexOffsets[vertexID];
        float2 pointOffsetX = float2(normalizedOffset.x, normalizedOffset.y) * pointOffset.x;
        float2 pointOffsetY = float2(-normalizedOffset.y, normalizedOffset.x) * pointOffset.y;

        //考虑到屏幕的比例,根据上面的两个Offset,得到新的裁剪空间的坐标
        float4 newClipPos = float4(-positionCS.w * (pointOffsetX + pointOffsetY) * (_ScreenParams.zw - 1.0) * _OutlineWidth, 0, 0) + positionCS;
        //算的不一定对,不过也大差不差了。。
        float lengthRadius = _OutlineWidth * length(pointOffsetX * (_ScreenParams.zw - 1.0));
        output.positionCS = newClipPos;
        //如果不乘上深度的话,会有透视变形的问题
        //https://en.wikipedia.org/wiki/Texture_mapping#Affine_texture_mapping
        output.uv = vertexUVs[vertexID] * (-newClipPos.w);
        output.ratio = 2.0 * lengthRadius / (2.0 * lengthRadius + lengthOffset);
        return output;
    }

    float GetDist(float2 uv, float ratio)
    {
        uv.x = 0.5 - abs(0.5 - uv.x);
        float2 coord = float2(uv.x * rcp(ratio), uv.y);
        float2 center = float2(0.5, 0.5);
        float distToCenter = length(coord - center);
        float distToLine = abs(uv.y - 0.5);

        return coord.x < 0.5 ? distToCenter : distToLine;
    }

    float4 Frag(Varyings input) : SV_TARGET
    {
        float2 uv = input.uv / (-input.positionCS.w);
        float distValue = GetDist(uv, input.ratio);
        //用smoothstep进行抗锯齿
        distValue = smoothstep(0.5, _Sharpness * 0.5, distValue);

        return float4(_OutlineColor.rgb, distValue * _OutlineColor.a);
    }

    ENDHLSL

    SubShader
    {
        Tags {"Queue"="Transparent" "RenderType"="Transparent" }
        ZWrite Off
        Blend SrcAlpha OneMinusSrcAlpha
        
        Pass
        {
            HLSLPROGRAM
            #pragma vertex Vert
            #pragma fragment Frag
            ENDHLSL
        }
    }
}

结语

我对目前的效果还是很满意的!也没看到有什么人做这个画线的操作(除了Shapes,当然我也没去搜索别的案例就是了)。感觉对绘制顶点时的处理和空间变换的理解又大大加深了!下一个目标就是描边的计算了,目前感觉上会和原始模型有深度测试的问题,只能走一步看一步了。

这段时间一直在思考景深和写ShaderToy,也算收获不少,不过没能写出博客来,拿这个等宽且抗锯齿的世界坐标直线来弥补一下。