线框绘制

线框渲染

最近写代码快写吐了,写点Shader缓解一下。
今天就复刻一个线框渲染吧,顺便温习一下几何着色器的用法。
参考列在前面

几种方式

总结一下各种实现的方法

  • 生成线框模型
  • 线框贴图
  • 写入UV
  • 利用GL来绘制线框
  • 几何着色器
    • 用几何着色器的LineStream来生成线段图元
    • 用几何着色器来生成片元相对线框的SDF,然后利用SDF绘制线框

生成线框模型

最直观最简单的方法了,比如Blender就自带线框修改器,可以吧模型之间转成线框模型输出。
缺点是显而易见的,模型的面数成倍增加,模型也不能复用,增加文件体积,以后模型要修改还要再重复这个过程。
好处就是简单,而且不受平台的限制,下面罗列的使用几何着色器的方法在比如Web平台就无法实现。
线框修改器

线框贴图

实现也比较容易,UV全展开不重叠,或者完全相同的重叠也行,然后在需要渲染线框的地方绘制就好。
想要程序化生成可以使用Blender的烘焙,把线框烘焙到贴图上输出即可。
这种方式对贴图的精度要求就很高了,分辨率会很大。
或者改进一下,参照之前模型上喷涂绘制的方式,改成用SDF来做,贴图可以使用很低的分辨率。
但这样程序化生成会比较麻烦

也可以和下面的几何着色器的方式结合起来,用之前喷涂的思路,把SDF烘焙到一张贴图上,就可以在不支持几何着色器的平台上使用了。

写入UV

还有一种骚操作,把模型改成四边面,然后每个面都完全展开UV,然后根据UV判断片元是否是边框。
缺点显而易见,每个面的大小不同,线框的大小也就各不相同了。

利用GL来绘制线框

来自这篇文章,我还真没有想到还有这种做法。
大致思路是读模型顶点,存到内存,然后调用GL.Vertex等方法一根一根的绘制。
感觉有点太暴力了,效率上会很低。但确实也算很简单有效的方法,而且自由度很高,针对一些特定场景也许很有用吧。

几何着色器

这里又大致分为两类

  1. 直接用几何着色器的LineStream来生成线段图元。
  2. 利用几何着色器来生成片元相对线框的SDF,然后利用SDF绘制线框。

先复习一下几何着色器的使用。

Shader "Custom/SimpleGeometryShader" {
    Properties {
        _Color ("Color", Color) = (1,1,1,1)
    }

    SubShader {
        Tags {"RenderType"="Opaque"}
        LOD 200

        Pass {
            HLSLPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #pragma geometry geom
            
            #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"

            struct appdata {
                float4 vertex : POSITION;
            };
            
            struct v2g {
                float4 vertex : SV_POSITION;
            };

            struct g2f {
                float4 vertex : SV_POSITION;
            };

            uniform float4 _Color;

            v2g vert (appdata v) {
                v2g o;
                o.vertex = TransformObjectToHClip(v.vertex);
                return o;
            }

            float4 frag (g2f i) : SV_Target {
                return _Color;
            }

            [maxvertexcount(3)]
            void geom(triangle v2g IN[3], inout TriangleStream<g2f> Out) {
                
                for(int i = 0; i < 3; i++) {
                    g2f o;
                    o.vertex = IN[i].vertex;
                    Out.Append(o);
                }
            }
            ENDHLSL
        }
    }
    FallBack "Diffuse"
}

这就是最最简单的几何着色器了,什么都没有干,输入什么就输出什么,没有一点改动。

LineStream

第一种很简单:

[maxvertexcount(3)]
void geom(triangle  v2g IN[3], inout LineStream<g2f> Out)
{
    g2f o1 = (g2f)0;
    g2f o2 = (g2f)0;
    g2f o3 = (g2f)0;
    o1.vertex = IN[0].vertex;
    o2.vertex = IN[1].vertex;
    o3.vertex = IN[2].vertex;
    
    Out.Append(o1);
    Out.Append(o2);
    Out.Append(o3);
}

但是这种方式只能生成线段了,如果想要扩充或者调整线段的粗细,就会很麻烦了。

四边形

剔除最长的那条边

[maxvertexcount(4)]
void geom(triangle v2g IN[3], inout LineStream<g2f> Out)
{
    g2f o1 = (g2f)0;
    g2f o2 = (g2f)0;
    g2f o3 = (g2f)0;
    o1.vertex = IN[0].vertex;
    o2.vertex = IN[1].vertex;
    o3.vertex = IN[2].vertex;
    
    float Edge1 = length(IN[0].modelPos - IN[1].modelPos);
    float Edge2 = length(IN[1].modelPos - IN[2].modelPos);
    float Edge3 = length(IN[2].modelPos - IN[0].modelPos);
    
    if (Edge1 > Edge2 && Edge1 > Edge3)
    {
        Out.Append(o2);
        Out.Append(o3);
        Out.RestartStrip();
        Out.Append(o3);
        Out.Append(o1);
        Out.RestartStrip();
    }
    else if (Edge2 > Edge1 && Edge2 > Edge3)
    {
        Out.Append(o1);
        Out.Append(o2);
        Out.RestartStrip();
        Out.Append(o3);
        Out.Append(o1);
        Out.RestartStrip();
    }
    else if (Edge3 > Edge1 && Edge3 > Edge2)
    {
        Out.Append(o1);
        Out.Append(o2);
        Out.RestartStrip();
        Out.Append(o2);
        Out.Append(o3);
        Out.RestartStrip();
    }
}

TriangleStream

第二种,生成片元相对线框的SDF。

插值

在g2f中增加一项


struct g2f {
    float4 vertex : SV_POSITION;
    float3 barycentric : TEXCOORD0;
};

几何着色器中手动赋值,并重新插值。

[maxvertexcount(3)]
void geom(triangle v2g IN[3], inout TriangleStream<g2f> Out) {
    g2f o1, o2, o3;
    o1.vertex = IN[0].vertex;
    o2.vertex = IN[1].vertex;
    o3.vertex = IN[2].vertex;
    o1.barycentric = float3(1, 0, 0);
    o2.barycentric = float3(0, 1, 0);
    o3.barycentric = float3(0, 0, 1);
    Out.Append(o1);
    Out.Append(o2);
    Out.Append(o3);
    Out.RestartStrip();
}

然后在片元中输出看看:
插值后结果

到边的距离

这就是片元中每个像素到各个顶点的距离了(每个分量),取最小值就是到各个边的距离:

float4 frag (g2f i) : SV_Target {
    float minV = min(i.barycentric.x, min(i.barycentric.y, i.barycentric.z));
    return float4(minV,minV,minV,1);
}

到边的SDF
然后用阈值去掐一下:
线框

剔除

看到背面被剔除了,再修改下,增加Cull Off

float4 frag (g2f i) : SV_Target {
    float minV = min(i.barycentric.x, min(i.barycentric.y, i.barycentric.z));
    float v = step(minV, _Size);
    clip(v-0.5);
    return float4(v,v,v,1);
}

背面显示

均匀线框

还有个问题,这里不同片元所在的三角大小不同,这个胶囊中间的三角形的线框就特别大。
所以在给每个顶点赋值时,不统一使用1,而是在模型空间算出其顶点到对边的距离:

[maxvertexcount(3)]
void geom(triangle v2g IN[3], inout TriangleStream<g2f> Out) {
    g2f o1, o2, o3;
    o1.vertex = IN[0].vertex;
    o2.vertex = IN[1].vertex;
    o3.vertex = IN[2].vertex;

    float dis1 = GetDistance(IN[1].modelPos,IN[2].modelPos,IN[0].modelPos);
    float dis2 = GetDistance(IN[0].modelPos,IN[2].modelPos,IN[1].modelPos);
    float dis3 = GetDistance(IN[0].modelPos,IN[1].modelPos,IN[2].modelPos);
    
    o1.barycentric = float3(dis1, 0, 0);
    o2.barycentric = float3(0, dis2, 0);
    o3.barycentric = float3(0, 0, dis3);
    Out.Append(o1);
    Out.Append(o2);
    Out.Append(o3);
    Out.RestartStrip();
}

均匀线框

四边形

再然后就是三角面边四角面,参照原文,剔除边长最长的那条边

float3 param = float3(0, 0, 0);

float Edge1 = length(IN[0].modelPos - IN[1].modelPos);
float Edge2 = length(IN[1].modelPos - IN[2].modelPos);
float Edge3 = length(IN[2].modelPos - IN[0].modelPos);
if (Edge1 > Edge2 && Edge1 > Edge3)
    param = float3(0, 0, 1);
else if (Edge2 > Edge1 && Edge2 > Edge3)
    param = float3(1, 0, 0);
else if (Edge3 > Edge1 && Edge3 > Edge2)
    param = float3(0, 1, 0);

o1.barycentric = float3(dis1, 0, 0) + param;
o2.barycentric = float3(0, dis2, 0) + param;
o3.barycentric = float3(0, 0, dis3) + param;

四边线框
这里有个小瑕疵,假如剔除的那个边,非常靠近四边形中的另一个三角形的边,他的SDF就不正确了。
四边线框
框中的被剔除的斜边,非常靠近另一个三角形的边,理应存在,但因为被剔除,所以不显示了。

抗锯齿

一个神奇的函数

float aa(float threshold, float distance)
{
    float delta = fwidth(distance) * _wireSmoothing;
    return smoothstep((threshold - 1) * delta, (threshold + 1) * delta, distance);
}

fwidth就不解释了,大体意思是锯齿越多的地方,fwidth(distance)值越大,delta越大,所以smoothstep的范围就越大,结果上看就会平滑一点。
但即使启用了抗锯齿,效果和原生自带的线框渲染也没法比,原生是真的把每根线在屏幕上按固定大小画出来,而这种方式必然会导致线距离相机过远而消失。


线框绘制
https://www.kuanmi.top/2023/02/09/wireframe/
作者
KuanMi
发布于
2023年2月9日
许可协议