Post

平面着色与线框着色(翻译二十一)

使用屏幕空间导数或几何着色器获取三角形法线,并利用重心坐标创建可配置宽度的线框效果。

平面着色与线框着色(翻译二十一)
  • 使用屏幕空间导数获取三角形法线。
  • 通过几何着色器做同样的事情。
  • 使用生成的重心坐标创建线框。
  • 让线框宽度固定并可配置。

导数与几何 (Derivatives and Geometry)

1. 平面着色 (Flat Shading)

网格由三角形组成,根据定义它们是平坦的。我们使用表面法线向量来增加曲率的错觉。这使得创建代表看似光滑表面的网格成为可能。然而,有时你实际上想显示平坦的三角形,无论是为了风格还是为了更好地查看网格的拓扑结构。

为了让三角形看起来像实际那样平坦,我们必须使用实际三角形的表面法线。这将给网格带来刻面外观,称为平面着色 (flat shading)。这可以通过使三角形三个顶点的法线向量等于三角形的法线向量来实现。这使得在三角形之间共享顶点成为不可能,因为那时它们也会共享法线。所以我们最终会得到更多的网格数据。如果我们可以继续共享顶点会很方便。此外,如果我们可以对任何网格使用平面着色材质,覆盖其原始法线(如果有的话),那就太好了。

除了平面着色,显示网格线框也很有用或很时尚。这使得网格的拓扑结构更加明显。理想情况下,我们可以在单个 pass 中使用自定义材质同时进行平面着色和线框渲染,适用于任何网格。要创建这样的材质,我们需要一个新的着色器。我们将使用渲染系列第 20 部分的最终着色器作为基础。复制 My First Lighting Shader 并将其名称更改为 Flat Wireframe

1
Shader "Custom/Flat Wireframe" { ... }

我们不是已经在编辑器中看到了线框吗?

我们确实可以在场景视图中看到线框,但在游戏视图和构建版本中看不到。所以如果你想在场景视图之外看到线框,你必须使用自定义解决方案。此外,场景视图仅显示原始网格的线框,无论着色器渲染什么其他东西。所以它不适用于曲面细分的顶点位移。

1.1 导数指令 (Derivative Instructions)

因为三角形是平坦的,它们的表面法线在其表面上的每一点都是相同的。因此,为三角形渲染的每个片段都应该使用相同的法线向量。但是我们目前不知道这个向量是什么。在顶点程序中,我们只能访问存储在网格中的顶点数据,这些数据是单独处理的。这里存储的法线向量对我们没有用,除非它被设计为代表三角形的法线。而在片段程序中,我们只能访问插值后的顶点法线。

为了确定表面法线,我们需要知道三角形在世界空间中的方向。这可以通过三角形顶点的位置来确定。假设三角形没有退化,其法线向量等于三角形两条边的归一化叉积。如果它是退化的,那么它反正也不会被渲染。所以给定三角形的顶点 $a, b, c$(逆时针顺序)。其法线向量为 $n = (c - a)\times(b - a)$。将其归一化给我们最终的单位法线向量 $\hat{n} = \frac{n} {\lvert n \rvert}$。

推导三角形法线
推导三角形法线

我们实际上不需要使用三角形的顶点。任何位于三角形平面内的三个点都可以,只要这些点也形成一个三角形。具体来说,我们只需要两个位于三角形平面内的向量,只要它们不平行且大于零。

一种可能性是使用对应于渲染片段的世界位置的点。例如,我们当前正在渲染的片段的世界位置 $p_0$,其右侧片段的位置,以及其上方片段的位置(在屏幕空间中)。

使用片段的世界位置
使用片段的世界位置

如果我们可以访问相邻片段的世界位置,这就行得通。没有办法直接访问相邻片段的数据,但我们可以访问此数据的屏幕空间导数。这是通过特殊指令完成的,这些指令告诉我们任何数据在屏幕空间 X 或 Y 维度上的片段间变化率。

例如,当前片段的世界位置是 $p_0$。下一个片段在屏幕空间 X 维度上的位置是 $p_x$。这两个片段之间世界位置在 X 维度上的变化率因此是 $\frac{\partial p}{\partial x} = p_x - p_0$。

这是世界位置在屏幕空间 X 维度上的偏导数。我们可以通过 ddx 函数在片段程序中检索此数据,为其提供世界位置。让我们在 My Lighting.cginc 中的 InitializeFragmentNormal 函数开始处执行此操作。

1
2
3
4
void InitializeFragmentNormal(inout Interpolators i) {
    float3 dpdx = ddx(i.worldPos);
    ...
}

我们可以对屏幕空间 Y 维度做同样的事情,通过调用 ddy 函数并传入世界位置来找到 $\frac{\partial p}{\partial y} = p_y - p_0$。

1
2
    float3 dpdx = ddx(i.worldPos);
    float3 dpdy = ddy(i.worldPos);

因为这些值代表片段世界位置之间的差异,它们定义了三角形的两条边。我们实际上不知道那个三角形的确切形状,但它保证位于原始三角形的平面内,这才是最重要的。所以最终的法线向量是那些向量的归一化叉积。用这个向量覆盖原始法线。

1
2
3
    float3 dpdx = ddx(i.worldPos);
    float3 dpdy = ddy(i.worldPos);
    i.normal = normalize(cross(dpdy, dpdx));

创建一个使用我们的 Flat Wireframe 着色器的新材质。任何使用此材质的网格都应使用平面着色渲染。它们将出现刻面外观,虽然当你也使用法线贴图时可能很难看到这一点。我在本教程的截图中使用了标准的胶囊体网格和灰色材质。

平滑着色与平面着色
平滑着色与平面着色

从远处看,胶囊体可能看起来像是由四边形组成的,但这些四边形每个都是由两个三角形组成的。

由三角形组成的四边形
由三角形组成的四边形

虽然这可行,但我们实际上改变了所有依赖 My Lighting 包含文件的着色器的行为。所以移除我们刚刚添加的代码。

1
2
3
//  float3 dpdx = ddx(i.worldPos);
//  float3 dpdy = ddy(i.worldPos);
//  i.normal = normalize(cross(dpdy, dpdx));

1.2 几何着色器 (Geometry Shaders)

还有另一种确定三角形法线的方法。我们可以使用实际的三角形顶点来计算法线向量,而不是使用导数指令。这需要我们按三角形进行工作,而不是按单个顶点或片段。这就是几何着色器发挥作用的地方。

几何着色器阶段位于顶点和片段阶段之间。它被馈送顶点程序的输出,按图元分组。几何程序可以在这些数据被插值并用于渲染片段之前修改它。

每个三角形处理顶点
每个三角形处理顶点

几何着色器的附加值是顶点按图元(primitive)馈送给它,在我们的例子中每个三角形三个顶点。网格三角形是否共享顶点并不重要,因为几何程序输出新的顶点数据。这允许我们推导三角形的法线向量并将其用作所有三个顶点的法线。

让我们把几何着色器的代码放在它自己的包含文件 MyFlatWireframe.cginc 中。让这个文件包含 My Lighting.cginc 并定义一个 MyGeometryProgram 函数。从一个空的 void 函数开始。

1
2
3
4
5
6
7
8
#if !defined(FLAT_WIREFRAME_INCLUDED)
#define FLAT_WIREFRAME_INCLUDED

#include "My Lighting.cginc"

void MyGeometryProgram () {}

#endif

几何着色器仅在针对 shader model 4.0 或更高版本时受支持。如果定义的较低,Unity 会自动将目标提升到此级别,但让我们明确一点。要实际使用几何着色器,我们必须添加 #pragma geometry 指令,就像顶点和片段函数一样。最后,必须包含 MyFlatWireframe 而不是 My Lighting。将这些更改应用于我们的 Flat Wireframe 着色器的基本、附加和延迟 pass。

1
2
3
4
5
6
7
8
9
10
11
12
    #pragma target 4.0

    ...

    #pragma vertex MyVertexProgram
    #pragma fragment MyFragmentProgram
    #pragma geometry MyGeometryProgram

    ...

    // #include "My Lighting.cginc"
    #include "MyFlatWireframe.cginc"

这将导致着色器编译器错误,因为我们要么还没有正确定义几何函数。我们必须声明它将输出多少个顶点。这个数字可以变化,所以我们必须提供一个最大值。因为我们处理的是三角形,我们将始终每次调用输出三个顶点。这是通过向我们的函数添加 maxvertexcount 属性来指定的,参数为 3。

1
2
[maxvertexcount(3)]
void GeometryProgram () {}

下一步是定义输入。由于我们处理的是插值前的顶点程序输出,数据类型是 InterpolatorsVertex。所以类型名称在这种情况下技术上是不正确的,但我们在命名时没有考虑到几何着色器。

1
2
[maxvertexcount(3)]
void MyGeometryProgram (InterpolatorsVertex i) {}

我们还必须声明我们正在处理哪种类型的图元,在我们的例子中是 triangle。这必须在输入类型之前指定。此外,由于三角形各有两个顶点,我们正在处理一个包含三个结构的数组。我们必须明确定义这一点。

1
2
[maxvertexcount(3)]
void MyGeometryProgram (triangle InterpolatorsVertex i[3]) {}

因为几何着色器可以输出的顶点数量各不相同,我们没有单一的返回类型。相反,几何着色器写入图元流。在我们的例子中,它是 TriangleStream,必须指定为 inout 参数。

1
2
3
4
5
[maxvertexcount(3)]
void MyGeometryProgram (
    triangle InterpolatorsVertex i[3],
    inout TriangleStream stream
) {}

TriangleStream 就像 C# 中的泛型类型。它需要知道我们要给它的顶点数据类型,这仍然是 InterpolatorsVertex

1
2
3
4
5
[maxvertexcount(3)]
void MyGeometryProgram (
    triangle InterpolatorsVertex i[3],
    inout TriangleStream<InterpolatorsVertex> stream
) {}

现在函数签名正确了,我们必须将顶点数据放入流中。这是通过调用流的 Append 函数来完成的,按我们接收它们的顺序对每个顶点调用一次。

1
2
3
4
5
6
7
8
9
[maxvertexcount(3)]
void MyGeometryProgram (
    triangle InterpolatorsVertex i[3],
    inout TriangleStream<InterpolatorsVertex> stream
) {
    stream.Append(i[0]);
    stream.Append(i[1]);
    stream.Append(i[2]);
}

此时我们的着色器再次工作了。我们添加了一个自定义几何阶段,它只是简单地通过顶点程序的输出,未作修改。

为什么几何程序看起来如此不同?

Unity 的着色器语法混合了 CG 和 HLSL 代码。大多时候它看起来像 CG,但在这种情况下它类似于 HLSL。

1.3 每个三角形修改顶点法线 (Modifying Vertex Normals Per Triangle)

要找到三角形的法线向量,首先提取其三个顶点的世界位置。

1
2
3
4
5
6
7
    float3 p0 = i[0].worldPos.xyz;
    float3 p1 = i[1].worldPos.xyz;
    float3 p2 = i[2].worldPos.xyz;

    stream.Append(i[0]);
    stream.Append(i[1]);
    stream.Append(i[2]);

现在我们可以执行归一化叉积,每个三角形一次。

1
2
3
4
5
    float3 p0 = i[0].worldPos.xyz;
    float3 p1 = i[1].worldPos.xyz;
    float3 p2 = i[2].worldPos.xyz;

    float3 triangleNormal = normalize(cross(p1 - p0, p2 - p0));

用这个三角形法线替换顶点法线。

1
2
3
4
5
6
7
8
9
    float3 triangleNormal = normalize(cross(p1 - p0, p2 - p0));

    i[0].normal = triangleNormal;
    i[1].normal = triangleNormal;
    i[2].normal = triangleNormal;

    stream.Append(i[0]);
    stream.Append(i[1]);
    stream.Append(i[2]);
再次,平面着色
再次,平面着色

我们最终得到了与之前相同的结果,但现在使用几何着色器阶段而不是依赖屏幕空间导数指令。

哪种方法最好?

如果你只需要平面着色,屏幕空间导数是实现该效果的最廉价方式。那么你还可以从网格数据中剥离法线——Unity 可以自动做到这一点——并且还可以移除法线插值器数据。一般来说,如果你能不使用自定义几何阶段,那就这样做。但我们将继续使用几何方法,因为我们在通过线框渲染时也需要它。


2. 渲染线框 (Rendering the Wireframe)

处理完平面着色后,我们继续渲染网格的线框。我们不打算创建新的几何体,也不会使用额外的 pass 来绘制线条。我们将通过在三角形内部、沿其边缘添加线条效果来创建线框视觉效果。这可以创建令人信服的线框,尽管定义形状轮廓的线条看起来只有内部线条的一半粗。这通常不是很明显,所以我们将接受这种不一致。

线框效果,轮廓线较细
线框效果,轮廓线较细

2.1 重心坐标 (Barycentric Coordinates)

为了给三角形边缘添加线条效果,我们需要知道片段到最近边缘的距离。这意味着关于三角形的拓扑信息需要在片段程序中可用。这可以通过将三角形的重心坐标添加到插值数据中来完成。

三角形内部的重心坐标
三角形内部的重心坐标

向三角形添加重心坐标的一种方法是使用网格的顶点颜色来存储它们。每个三角形的第一个顶点变为红色,第二个变为绿色,第三个变为蓝色。然而,这将需要以此方式分配顶点颜色的网格,并且使得无法共享顶点。我们想要一个适用于任何网格的解决方案。幸运的是,我们可以使用几何程序来添加所需的坐标。

因为重心坐标不是由网格提供的,顶点程序不知道它们。所以它们不是 InterpolatorsVertex 结构的一部分。为了让几何程序输出它们,我们必须定义一个新的结构。首先在 MyGeometryProgram 上方定义 InterpolatorsGeometry。它应该包含与 InterpolatorsVertex 相同的数据,所以使用它作为内容。

1
2
3
struct InterpolatorsGeometry {
    InterpolatorsVertex data;
};

调整 MyGeometryProgram 的流数据类型,使其使用新结构。在函数内部定义此类型的变量,将输入数据分配给它们,并将它们追加到流中,而不是直接传递输入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[maxvertexcount(3)]
void MyGeometryProgram (
    triangle InterpolatorsVertex i[3],
    inout TriangleStream<InterpolatorsGeometry> stream
) {
    ...

    InterpolatorsGeometry g0, g1, g2;
    g0.data = i[0];
    g1.data = i[1];
    g2.data = i[2];

    stream.Append(g0);
    stream.Append(g1);
    stream.Append(g2);
}

现在我们可以向 InterpolatorsGeometry 添加额外数据。给它一个 float3 barycentricCoordinates 向量,使用第十个插值器语义。

1
2
3
4
struct InterpolatorsGeometry {
    InterpolatorsVertex data;
    float3 barycentricCoordinates : TEXCOORD9;
};

给每个顶点一个重心坐标。哪个顶点得到哪个坐标并不重要,只要它们是有效的。

1
2
3
4
5
6
7
    g0.barycentricCoordinates = float3(1, 0, 0);
    g1.barycentricCoordinates = float3(0, 1, 0);
    g2.barycentricCoordinates = float3(0, 0, 1);

    stream.Append(g0);
    stream.Append(g1);
    stream.Append(g2);

注意重心坐标总和为 1。所以我们其实只需要传递两个,通过从 1 中减去另外两个来推导第三个坐标。这意味着我们少插值一个数字,所以让我们做这个改变。

1
2
3
4
5
6
7
8
9
10
struct InterpolatorsGeometry {
    InterpolatorsVertex data;
    float2 barycentricCoordinates : TEXCOORD9;
};

...

    g0.barycentricCoordinates = float2(1, 0);
    g1.barycentricCoordinates = float2(0, 1);
    g2.barycentricCoordinates = float2(0, 0);

我们的重心坐标现在是用重心坐标插值的吗?

是的。不幸的是,我们不能直接使用用于插值顶点数据的重心坐标。GPU 可能会出于各种原因在我们在顶点程序中结束之前将三角形分割成更小的三角形。所以 GPU 用于最终插值的坐标可能与预期的不同。

2.2 定义额外的插值器 (Defining Extra Interpolators)

此时我们将重心坐标传递给片段程序,但它还不知道它们。我们必须将它们添加到 My LightingInterpolators 的定义中。但我们不能简单地假设这些数据是可用的。只有我们的 Flat Wireframe 着色器是这种情况。所以让我们通过定义一个 CUSTOM_GEOMETRY_INTERPOLATORS 宏,使得任何使用 My Lighting 的人都可以定义通过几何着色器提供的自己的插值器数据。为了支持这一点,如果该宏在此时已定义,则将其插入到 Interpolators 中。

1
2
3
4
5
6
struct Interpolators {
    ...
#if defined (CUSTOM_GEOMETRY_INTERPOLATORS)
    CUSTOM_GEOMETRY_INTERPOLATORS
#endif
};

现在我们可以在 MyFlatWireframe 中定义此宏。我们必须在包含 My Lighting 之前这样做。我们也可以在 InterpolatorsGeometry 中使用它,这样我们只需要编写一次代码。

1
2
3
4
5
6
7
8
9
10
#define CUSTOM_GEOMETRY_INTERPOLATORS 
    float2 barycentricCoordinates : TEXCOORD9;

#include "My Lighting.cginc"

struct InterpolatorsGeometry {
    InterpolatorsVertex data;
//  float2 barycentricCoordinates : TEXCOORD9;
    CUSTOM_GEOMETRY_INTERPOLATORS
};

为什么我会收到转换编译错误?

如果你使用的是渲染 20 中的包,那是由于一个教程错误。My Lighting 中的 ComputeVertexLightColor 函数应该使用 InterpolatorsVertex 作为其参数类型,但不正确地使用了 Interpolators。修复此错误,错误就会消失。如果你使用的是自己的代码,你可能会在某个地方使用错误的插值器结构类型时遇到类似的错误。

2.3 拆分 My Lighting (Splitting My Lighting)

我们将如何使用重心坐标来可视化线框?无论我们怎么做,My Lighting 都不应该参与其中。相反,我们可以通过插入我们自己的函数使其代码中的功能可重连。

要覆盖 My Lighting 的功能,我们必须在包含该文件之前定义新代码。但要这样做,我们需要访问 Interpolators,它定义在 My Lighting 中,所以我们必须先包含它。为了解决这个问题,我们必须将 My Lighting 拆分为两个文件。复制 My Lighting 开头的代码,包括 include 语句、插值器结构和所有 Get 函数。将此代码放入一个新的 My Lighting Input.cginc 文件中。给该文件它自己的包含保护定义 MY_LIGHTING_INPUT_INCLUDED

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#if !defined(MY_LIGHTING_INPUT_INCLUDED)
#define MY_LIGHTING_INPUT_INCLUDED

#include "UnityPBSLighting.cginc"
#include "AutoLight.cginc"

#if defined(FOG_LINEAR) || defined(FOG_EXP) || defined(FOG_EXP2)
    #if !defined(FOG_DISTANCE)
        #define FOG_DEPTH 1
    #endif
    #define FOG_ON 1
#endif

...

float3 GetEmission (Interpolators i) {
    #if defined(FORWARD_BASE_PASS) || defined(DEFERRED_PASS)
        #if defined(_EMISSION_MAP)
            return tex2D(_EmissionMap, i.uv.xy) * _Emission;
        #else
            return _Emission;
        #endif
    #else
        return 0;
    #endif
}

#endif

My Lighting 中删除相同的代码。为了保持现有着色器工作,包含 My Lighting Input

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#if !defined(MY_LIGHTING_INCLUDED)
#define MY_LIGHTING_INCLUDED

//#include "UnityPBSLighting.cginc"
// ...
//
//float3 GetEmission (Interpolators i) {
// ...
//}

#include "My Lighting Input.cginc"

void ComputeVertexLightColor (inout InterpolatorsVertex i) {
    #if defined(VERTEXLIGHT_ON)
        i.vertexLightColor = Shade4PointLights(
            unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
            unity_LightColor[0].rgb, unity_LightColor[1].rgb,
            unity_LightColor[2].rgb, unity_LightColor[3].rgb,
            unity_4LightAtten0, i.worldPos.xyz, i.normal
        );
    #endif
}

现在可以在包含 My Lighting 之前包含 My Lighting Input。其包含保护将确保防止重复包含。在 MyFlatWireframe 中这样做。

1
2
#include "My Lighting Input.cginc"
#include "My Lighting.cginc"

2.4 重连反照率 (Rewiring Albedo)

让我们通过调整材质的反照率来添加线框效果。这要求我们替换 My Lighting 的默认反照率函数。就像自定义几何插值器一样,我们将通过宏 ALBEDO_FUNCTION 来完成此操作。在 My Lighting 中,在我们确定输入已被包含之后,检查此宏是否已定义。如果没有,将其定义为 GetAlbedo 函数,使其成为默认值。

1
2
3
4
5
#include "My Lighting Input.cginc"

#if !defined(ALBEDO_FUNCTION)
    #define ALBEDO_FUNCTION GetAlbedo
#endif

MyFragmentProgram 函数中,将 GetAlbedo 的调用替换为宏。

1
2
3
    float3 albedo = DiffuseAndSpecularFromMetallic(
        ALBEDO_FUNCTION(i), GetMetallic(i), specularTint, oneMinusReflectivity
    );

现在我们可以在 MyFlatWireframe 中创建我们自己的反照率函数,在包含 My Lighting Input 之后。它需要具有与原始 GetAlbedo 函数相同的形式。首先简单地传递原始函数的结果。之后,用我们自己的函数名称定义 ALBEDO_FUNCTION 宏,然后包含 My Lighting

1
2
3
4
5
6
7
8
9
10
#include "My Lighting Input.cginc"

float3 GetAlbedoWithWireframe (Interpolators i) {
    float3 albedo = GetAlbedo(i);
    return albedo;
}

#define ALBEDO_FUNCTION GetAlbedoWithWireframe

#include "My Lighting.cginc"

为了验证我们确实控制了片段的反照率,直接使用重心坐标作为反照率。

1
2
3
4
5
6
7
8
float3 GetAlbedoWithWireframe (Interpolators i) {
    float3 albedo = GetAlbedo(i);
    float3 barys;
    barys.xy = i.barycentricCoordinates;
    barys.z = 1 - barys.x - barys.y;
    albedo = barys;
    return albedo;
}
重心坐标作为反照率
重心坐标作为反照率

2.5 创建线条 (Creating Wires)

要创建线框效果,我们需要知道片段离最近的三角形边缘有多近。我们可以通过取重心坐标的最小值来找到这一点。这给了我们在重心域中到边缘的最小距离。让我们直接将其用作反照率。

1
2
3
4
5
6
7
    float3 albedo = GetAlbedo(i);
    float3 barys;
    barys.xy = i.barycentricCoordinates;
    barys.z = 1 - barys.x - barys.y;
    // albedo = barys;
    float minBary = min(barys.x, min(barys.y, barys.z));
    return albedo * minBary;
最小重心坐标
最小重心坐标

这看起来有点像白色网格顶部的黑色线框,但太模糊了。这是因为到最近边缘的距离从边缘的零变为三角形中心的 $\frac{1}{3}$。为了使其看起来更像细线,我们必须更快地淡入白色,例如通过在 0 到 0.1 之间从黑色过渡到白色。为了使过渡平滑,让我们使用 smoothstep 函数。

什么是 smoothstep 函数?

它是一个标准函数,产生两个值之间的平滑曲线过渡,而不是线性插值。它的定义是 $3t^2 - 2t^3$,其中 $t$ 从 0 变为 1。

Smoothstep vs. 线性过渡
Smoothstep vs. 线性过渡

smoothstep 函数有三个参数,$a, b, c$。前两个参数 $a$ 和 $b$ 定义过渡应该覆盖的范围,而 $c$ 是要平滑的值。这导致 $t = \frac{c - a}{b - a}$,在使用前被限制在 0-1 之间。

1
2
3
    float minBary = min(barys.x, min(barys.y, barys.z));
    minBary = smoothstep(0, 0.1, minBary);
    return albedo * minBary;
调整后的过渡
调整后的过渡

2.6 固定线宽 (Fixed Wire Width)

线框效果开始看起来不错了,但只适用于边长大致相同的三角形。此外,线条受视距影响,因为它们是三角形的一部分。理想情况下,线条具有固定的视觉粗细。

为了在屏幕空间中保持线条粗细恒定,我们必须调整我们用于 smoothstep 函数的范围。范围取决于测量的到边缘的距离在视觉上变化有多快。我们可以使用屏幕空间导数指令来找出这一点。

变化率对于两个屏幕空间维度可能是不同的。我们应该使用哪一个?我们可以使用两者,简单地将它们相加。此外,因为变化可能是正的或负的,我们应该使用它们的绝对值。通过直接使用结果作为范围,我们最终得到覆盖大约两个片段的线条。

1
2
3
    float minBary = min(barys.x, min(barys.y, barys.z));
    float delta = abs(ddx(minBary)) + abs(ddy(minBary));
    minBary = smoothstep(0, delta, minBary);

这个公式也可以作为方便的 fwidth 函数使用,所以让我们使用它。

1
    float delta = fwidth(minBary);
固定宽度的线条
固定宽度的线条

结果线条可能看起来有点太细。我们可以通过将过渡稍微移离边缘来解决这个问题,例如通过我们用于混合范围的相同值。

1
    minBary = smoothstep(delta, 2 * delta, minBary);
线条更粗,但有伪影
线条更粗,但有伪影

这产生了更清晰的线条,但也揭示了三角形角附近的线条中的锯齿伪影。出现伪影是因为最近的边缘在这些区域突然改变,这导致不连续的导数。为了解决这个问题,我们必须使用各个重心坐标的导数,分别混合它们,并在那之后取最小值。

1
2
3
4
5
6
7
    barys.z = 1 - barys.x - barys.y;
    float3 deltas = fwidth(barys);
    barys = smoothstep(deltas, 2 * deltas, barys);
    float minBary = min(barys.x, min(barys.y, barys.z));
//  float delta = fwidth(minBary);
//  minBary = smoothstep(delta, 2 * delta, minBary);
    return albedo * minBary;
没有伪影的线框
没有伪影的线框

2.7 可配置的线条 (Configurable Wires)

我们有了一个可用的线框效果,但你可能想要使用不同的线条粗细、混合区域或颜色。也许你想为每个材质使用不同的设置。所以让我们使其可配置。为此,向 Flat Wireframe 着色器添加三个属性。第一个是线框颜色,默认为黑色。第二个是线框平滑度,控制过渡范围。0 到 10 的范围应该足够了,默认为 1,代表 fwidth 测量的倍数。第三个是线框粗细,具有与平滑度相同的设置。

1
2
3
    _WireframeColor ("Wireframe Color", Color) = (0, 0, 0)
    _WireframeSmoothing ("Wireframe Smoothing", Range(0, 10)) = 1
    _WireframeThickness ("Wireframe Thickness", Range(0, 10)) = 1

将相应的变量添加到 MyFlatWireframe 并在 GetAlbedoWithWireframe 中使用它们。基于平滑后的最小值,通过在线框颜色和原始反照率之间插值来确定最终反照率。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
float3 _WireframeColor;
float _WireframeSmoothing;
float _WireframeThickness;

float3 GetAlbedoWithWireframe (Interpolators i) {
    float3 albedo = GetAlbedo(i);
    float3 barys;
    barys.xy = i.barycentricCoordinates;
    barys.z = 1 - barys.x - barys.y;
    float3 deltas = fwidth(barys);
    float3 smoothing = deltas * _WireframeSmoothing;
    float3 thickness = deltas * _WireframeThickness;
    barys = smoothstep(thickness, thickness + smoothing, barys);
    float minBary = min(barys.x, min(barys.y, barys.z));
    // return albedo * minBary;
    return lerp(_WireframeColor, albedo, minBary);
}

虽然着色器现在是可配置的,但属性还没有出现在我们的自定义着色器 GUI 中。我们可以为 Flat Wireframe 创建一个新的 GUI,但让我们使用快捷方式并将属性直接添加到 MyLightingShaderGUI。给它一个新的 DoWireframe 方法来为线框创建一个小部分。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
    void DoWireframe () {
        GUILayout.Label("Wireframe", EditorStyles.boldLabel);
        EditorGUI.indentLevel += 2;
        editor.ShaderProperty(
            FindProperty("_WireframeColor"),
            MakeLabel("Color")
        );
        editor.ShaderProperty(
            FindProperty("_WireframeSmoothing"),
            MakeLabel("Smoothing", "In screen space.")
        );
        editor.ShaderProperty(
            FindProperty("_WireframeThickness"),
            MakeLabel("Thickness", "In screen space.")
        );
        EditorGUI.indentLevel -= 2;
    }

为了让 MyLightingShaderGUI 同时支持带和不带线框的着色器,只有当着色器具有 _WireframeColor 属性时才在其 OnGUI 方法中调用 DoWireframe。我们简单地假设如果该属性可用,它就拥有全部三个属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    public override void OnGUI (
        MaterialEditor editor, MaterialProperty[] properties
    ) {
        this.target = editor.target as Material;
        this.editor = editor;
        this.properties = properties;
        DoRenderingMode();
        if (target.HasProperty("_WireframeColor")) {
            DoWireframe();
        }
        DoMain();
        DoSecondary();
        DoAdvanced();
    }
可配置的线框
可配置的线框

你现在能够渲染带有平面着色和可配置线框的网格了。这将在下一个高级渲染教程曲面细分中派上用场。

This post is licensed under CC BY 4.0 by the author.