Post

自定义管线:遮罩、细节与法线贴图 (翻译八)

自定义管线:遮罩、细节与法线贴图 (翻译八)
  • 创建一个电路板般的材质。
  • 添加对 MODS 蒙版贴图的支持。
  • 引入一个辅助细节贴图。
  • 执行切线空间法线贴图。

背景

到目前为止,我们一直使用非常简单的材质来测试渲染管线。但它也应该支持复杂的材质,这样我们可以表现更有趣的表面。在本教程中,我们将借助几张纹理创建一个艺术风格的电路材质。

漫反射贴图

我们材质的核心是漫反射贴图(Albedo Map)。它由几层不同深浅的绿色和金色组成。每个颜色区域都是均匀的,除了有一些棕色污渍,这使得我们之后添加的细节更容易区分。

漫反射贴图
漫反射贴图
漫反射贴图

使用我们的 Lit Shader 创建一个新材质,并应用这个漫反射贴图。我将平铺(Tiling)设置为 2×1,这样方形纹理在包裹球体时不会过度拉伸。默认球体的极点总是会变形严重,这是无法避免的。

电路球体
电路球体

自发光

我们已经支持自发光贴图,所以让我们使用一张在金色电路上方添加浅蓝色发光图案的贴图。

自发光贴图
自发光贴图
自发光贴图

将其分配给材质,并将自发光颜色设置为白色,使其可见。

发光的电路效果
发光的电路效果

遮罩贴图

目前我们无法做更多的事情来让我们的材质更有趣。金色电路应该是金属的,而绿色板不是,但我们目前只能配置统一的金属度和平滑度值。我们需要额外的贴图来支持在表面上变化这些属性。

金属度1和平滑度0.95
金属度1和平滑度0.95

MODS 贴图

我们可以为金属度添加一张单独的贴图,为平滑度再添加一张,但两者都只需要一个通道,所以我们可以将它们合并到一张贴图中。这就是所谓的遮罩贴图(Mask Map),它的各个通道遮罩不同的 Shader 属性。

我们将使用与 Unity HDRP 相同的格式,即 MODS 贴图。它代表金属度(Metallic)、遮蔽(Occlusion)、细节(Detail)和平滑度(Smoothness),按此顺序存储在 RGBA 通道中。

MODS遮罩贴图
MODS遮罩贴图

这是我们电路的这样一张贴图。它在所有通道中都有数据,但目前我们只使用它的 R 和 A 通道。由于此纹理包含遮罩数据而不是颜色,请确保禁用其 sRGB(Color Texture)纹理导入属性。不这样做会导致 GPU 在采样纹理时错误地应用伽马到线性转换。

遮罩输入

为 Lit 添加一个遮罩贴图属性。因为它是遮罩,我们将使用白色作为默认值,这不会改变任何内容。

1
2
3
[NoScaleOffset] _MaskMap("Mask (MODS)", 2D) = "white" {}
_Metallic ("Metallic", Range(0, 1)) = 0
_Smoothness ("Smoothness", Range(0, 1)) = 0.5
遮罩Shader属性
遮罩Shader属性

在 LitInput 中添加一个 GetMask 函数,它只需采样遮罩纹理并返回它。

1
2
3
4
5
6
7
TEXTURE2D(_BaseMap);
TEXTURE2D(_MaskMap);
...

float4 GetMask (float2 baseUV) {
    return SAMPLE_TEXTURE2D(_MaskMap, sampler_BaseMap, baseUV);
}

在继续之前,让我们也整理一下 LitInput 代码。定义一个 INPUT_PROP 宏,带有 name 参数,作为使用 UNITY_ACCESS_INSTANCED_PROP 宏的简写。

1
#define INPUT_PROP(name) UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, name)

现在我们可以简化所有 getter 函数的代码。我只展示了检索 _BaseMap_ST 的更改(在 TransformBaseUV 中)。

1
float4 baseST = INPUT_PROP(_BaseMap_ST);

这个更改也可以应用于 UnlitInput 中的代码。

金属度

LitPass 不需要知道某些属性是否依赖遮罩贴图。各个函数可以在需要时检索遮罩。在 GetMetallic 中这样做,通过乘法用遮罩的 R 通道遮罩其结果。

1
2
3
4
5
float GetMetallic (float2 baseUV) {
    float metallic = INPUT_PROP(_Metallic);
    metallic *= GetMask(baseUV).r;
    return metallic;
}
仅金色电路是金属的
仅金色电路是金属的
仅金色电路是金属的

金属度贴图通常是二进制的。在我们的例子中,金色电路完全是金属的,而绿色板不是。金色污渍区域是个例外,金属度稍低。

平滑度

在 GetSmoothness 中做同样的事情,这次依赖遮罩的 A 通道。金色电路非常光滑,而绿色板不是。

1
2
3
4
5
float GetSmoothness (float2 baseUV) {
    float smoothness = INPUT_PROP(_Smoothness);
    smoothness *= GetMask(baseUV).a;
    return smoothness;
}
平滑度贴图的使用
平滑度贴图的使用

遮蔽

遮罩的 G 通道包含遮蔽数据。想法是,像缝隙和孔洞这样的小凹陷区域大部分被物体的其余部分遮蔽,但如果这些特征仅由纹理表示,则照明会忽略这一点。遮罩提供了缺失的遮蔽数据。添加一个新的 GetOcclusion 函数来获取它,最初始终返回零以展示其最大效果。

1
2
3
float GetOcclusion (float2 baseUV) {
    return 0.0;
}

将遮蔽数据添加到 Surface 结构体。

1
2
3
4
5
6
7
struct Surface {
    ...
    float occlusion;
    float smoothness;
    float fresnelStrength;
    float dither;
};

并在 LitPassFragment 中初始化它。

1
2
3
surface.metallic = GetMetallic(input.baseUV);
surface.occlusion = GetOcclusion(input.baseUV);
surface.smoothness = GetSmoothness(input.baseUV);

遮蔽仅适用于间接环境照明。直接光不受影响,所以当光源直接指向缝隙时,它们不会保持黑暗。因此,我们使用遮蔽来调节 IndirectBRDF 的结果。

1
2
3
4
5
6
float3 IndirectBRDF (
    Surface surface, BRDF brdf, float3 diffuse, float3 specular
) {
    ...
    return (diffuse * brdf.diffuse + reflection) * surface.occlusion;
}
完全遮蔽
完全遮蔽

在验证它可以工作后,让 GetOcclusion 返回遮罩的 G 通道。

1
2
3
float GetOcclusion (float2 baseUV) {
    return GetMask(baseUV).g;
}
遮蔽贴图的使用
遮蔽贴图的使用

绿色板的一些部分比其他部分低,因此它们应该被稍微遮蔽。区域很大,遮蔽贴图处于最大强度以使效果清晰可见,但结果太强了,不合理。让我们添加一个遮蔽强度滑块属性到我们的 Shader,而不是创建另一个具有更好遮蔽数据的遮罩贴图。

1
2
3
4
[NoScaleOffset] _MaskMap("Mask (MODS)", 2D) = "white" {}
_Metallic ("Metallic", Range(0, 1)) = 0
_Occlusion ("Occlusion", Range(0, 1)) = 1
_Smoothness ("Smoothness", Range(0, 1)) = 0.5
遮蔽滑块;降低到0.5
遮蔽滑块;降低到0.5

将其添加到 UnityPerMaterial 缓冲区。

1
2
3
4
5
6
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
...
UNITY_DEFINE_INSTANCED_PROP(float, _Occlusion)
UNITY_DEFINE_INSTANCED_PROP(float, _Smoothness)
UNITY_DEFINE_INSTANCED_PROP(float, _Fresnel)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

然后调整 GetOcclusion,使其用属性调节遮罩。在这种情况下,滑块控制遮罩的强度,所以如果设置为零,遮罩应该被完全忽略。我们可以通过根据强度在遮罩和 1 之间进行插值来实现。

1
2
3
4
5
6
float GetOcclusion (float2 baseUV) {
    float strength = INPUT_PROP(_Occlusion);
    float occlusion = GetMask(baseUV).g;
    occlusion = lerp(occlusion, 1.0, strength);
    return occlusion;
}
半强度遮蔽
半强度遮蔽

细节贴图

下一步是为我们的材质添加一些细节。我们通过使用比基础贴图更高的平铺采样细节纹理,并将其与基础数据和遮罩数据结合来实现。这使表面更有趣,并在近距离观察表面时提供更高分辨率的信息,在那里基础贴图本身会显得像素化。

细节贴图
细节贴图

细节应该只稍微修改表面属性,因此我们将再次在单个非颜色贴图中组合数据。HDRP 使用 ANySNx 格式,这意味着它在 R 中存储漫反射调节,在 B 中存储平滑度调节,在 AG 中存储细节法向量的 XY 分量。但我们的贴图不包含法向量,所以我们只使用 RB 通道。因此它是 RGB 纹理,不是 RGBA。

细节贴图;非sRGB
细节贴图;非sRGB

细节 UV 坐标

因为细节贴图应该使用比基础贴图更高的平铺,所以它需要自己的平铺和偏移。为它添加一个材质属性,这次不使用 NoScaleOffset 属性。它的默认值应该不引起任何变化,我们通过使用 linearGrey 来实现,因为 0.5 的值被认为是中性的。

1
2
3
[NoScaleOffset] _EmissionMap("Emission", 2D) = "white" {}
[HDR] _EmissionColor("Emission", Color) = (0.0, 0.0, 0.0, 0.0)
_DetailMap("Details", 2D) = "linearGrey" {}
细节贴图属性;平铺增加四倍
细节贴图属性;平铺增加四倍

将所需的纹理、采样器状态和缩放偏移属性添加到 LitInput,以及一个 TransformDetailUV 函数来变换细节纹理坐标。

1
2
3
4
5
6
7
8
9
10
11
12
TEXTURE2D(_DetailMap);
SAMPLER(sampler_DetailMap);
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
    UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)
    UNITY_DEFINE_INSTANCED_PROP(float4, _DetailMap_ST)
    ...
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
...
float2 TransformDetailUV (float2 detailUV) {
    float4 detailST = INPUT_PROP(_DetailMap_ST);
    return detailUV * detailST.xy + detailST.zw;
}

然后添加一个 GetDetail 函数来给定细节 UV 检索所有细节数据。

1
2
3
4
float4 GetDetail (float2 detailUV) {
    float4 map = SAMPLE_TEXTURE2D(_DetailMap, sampler_DetailMap, detailUV);
    return map;
}

在 LitPassVertex 中变换坐标并通过 Varyings 传递它们。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Varyings {
    ...
    float2 baseUV : VAR_BASE_UV;
    float2 detailUV : VAR_DETAIL_UV;
    ...
};

Varyings LitPassVertex (Attributes input) {
    ...
    output.baseUV = TransformBaseUV(input.baseUV);
    output.detailUV = TransformDetailUV(input.baseUV);
    return output;
}

细节漫反射

要将细节添加到漫反射,我们必须在 GetBase 中添加一个细节 UV 参数,默认将其设置为零,这样现有代码不会中断。首先,简单地将所有细节直接添加到基础贴图,然后再考虑颜色 tint。

1
2
3
4
5
6
7
float4 GetBase (float2 baseUV, float2 detailUV = 0.0) {
    float4 map = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, baseUV);
    float4 color = INPUT_PROP(_BaseColor);
    float4 detail = GetDetail(detailUV);
    map += detail;
    return map * color;
}

然后在 LitPassFragment 中将细节 UV 传递给它。

1
float4 base = GetBase(input.baseUV, input.detailUV);
漫反射细节已添加
漫反射细节已添加

这确认了细节数据被正确采样,但我们还没有正确解释它。首先,0.5 的值是中性的。更高的值应该增加或变亮,而更低的值应该减少或变暗。实现这一工作的第一步是在 GetDetail 中将细节值范围从 0–1 转换为 −1–1。

1
2
3
4
float4 GetDetail (float2 detailUV) {
    float4 map = SAMPLE_TEXTURE2D(_DetailMap, sampler_DetailMap, detailUV);
    return map * 2.0 - 1.0;
}

其次,只有 R 通道影响漫反射,将其推向黑色或白色。这可以通过根据细节的符号将颜色与 0 或 1 进行插值来完成。然后插值器是细节的绝对值。这应该只影响漫反射,不影响基础的 alpha 通道。

1
2
float detail = GetDetail(detailUV).r;
map.rgb = lerp(map.rgb, detail < 0.0 ? 0.0 : 1.0, abs(detail));
漫反射插值
漫反射插值

这可以工作,并且非常明显,因为我们的细节贴图非常强。但变亮效果看起来比变暗效果更强。这是因为我们在线性空间中应用修改。在伽马空间中执行它会更好地匹配视觉上相等的分布。我们可以通过插值漫反射的平方根,然后再平方来近似它。

1
2
map.rgb = lerp(sqrt(map.rgb), detail < 0.0 ? 0.0 : 1.0, abs(detail));
map.rgb *= map.rgb;
感知插值;变暗效果看起来更强
感知插值;变暗效果看起来更强

细节目前被应用到整个表面,但想法是大部分金色电路不受影响。这就是细节遮罩的用途,存储在遮罩贴图的 B 通道中。我们可以通过将其因子化到插值器中来应用它。

1
2
float mask = GetMask(baseUV).b;
map.rgb = lerp(sqrt(map.rgb), detail < 0.0 ? 0.0 : 1.0, abs(detail) * mask);
遮罩细节
遮罩细节

我们的细节处于最大可能的强度,这太强了。让我们引入一个细节漫反射强度滑块属性来缩小它们。

1
2
_DetailMap("Details", 2D) = "linearGrey" {}
_DetailAlbedo("Detail Albedo", Range(0, 1)) = 1

将其添加到 UnityPerMaterial 并在 GetBase 中将其与细节相乘。

1
2
3
4
5
6
7
8
9
10
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
    ...
    UNITY_DEFINE_INSTANCED_PROP(float, _DetailAlbedo)
    UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
...
float4 GetBase (float2 baseUV, float2 baseUV2 = 0.0) {
    ...
    float detail = GetDetail(detailUV).r * INPUT_PROP(_DetailAlbedo);
    ...
}
漫反射细节缩小到0.2
漫反射细节缩小到0.2

细节平滑度

为平滑度添加细节的方式相同。首先,也为它添加一个强度滑块属性。

1
2
_DetailAlbedo("Detail Albedo", Range(0, 1)) = 1
_DetailSmoothness("Detail Smoothness", Range(0, 1)) = 1

然后将属性添加到 UnityPerMaterial,在 GetSmoothness 中检索缩放的细节,并以相同的方式进行插值。这次我们需要细节贴图的 B 通道。

1
2
3
4
5
6
7
8
9
10
11
12
13
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
    ...
    UNITY_DEFINE_INSTANCED_PROP(float, _DetailSmoothness)
    UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
...
float GetSmoothness (float2 baseUV, float2 detailUV = 0.0) {
    float smoothness = INPUT_PROP(_Smoothness);
    smoothness *= GetMask(baseUV).a;
    float detail = GetDetail(detailUV).b * INPUT_PROP(_DetailSmoothness);
    float mask = GetMask(baseUV).b;
    smoothness = lerp(smoothness, detail < 0.0 ? 0.0 : 1.0, abs(detail) * mask);
    return smoothness;
}

让 LitPassFragment 也将细节 UV 传递给 GetSmoothness。

1
surface.smoothness = GetSmoothness(input.baseUV, input.detailUV);
平滑度细节;0.2和全强度
平滑度细节;0.2和全强度

淡出细节

细节只有在足够大时才重要。细节太小时不应该应用,因为那会产生噪点结果。Mip mapping 像往常一样模糊数据,但对于细节,我们希望更进一步,让它们也淡出。

全强度细节
全强度细节

如果我们启用细节纹理的 Fadeout Mip Maps 导入选项,Unity 可以为我们自动淡出细节。会出现一个范围滑块,控制从哪个 mip 级别开始淡入淡出。Unity 只是将 mip maps 插值到灰色,这意味着贴图变为中性。为此,纹理的 Filter Mode 必须设置为 Trilinear,这应该自动发生。

淡出细节
淡出细节

法线贴图

尽管我们让表面复杂得多,但它看起来仍然是平的,因为确实如此。光照与表面法线交互,法线在每个三角形上平滑插值。如果光照也与它的较小特征交互,我们的表面会更可信。我们可以通过添加法线贴图支持来实现这一点。

通常法线贴图是从高多边形密度的 3D 模型生成的,然后烘焙到低多边形模型以供实时使用。丢失的高多边形几何体的法线向量被烘焙到法线贴图中。或者,法线贴图是程序化生成的。这是我们电路的这样一张贴图。导入后将其纹理类型设置为 Normal map。

法线贴图
法线贴图

这个贴图遵循标准的切线空间法线贴图约定,将向上轴(在本例中为 Z)存储在 B 通道中,而向右和向前的 XY 轴存储在 RG 中。就像细节贴图一样,法线分量的 −1-1 范围被转换,使 0.5 成为中点。因此平面区域看起来偏蓝。

采样法线

为了采样法线,我们必须在 Shader 中添加一个法线贴图纹理属性,bump 作为其默认值,表示平面贴图。还要添加一个法线缩放属性,这样我们可以控制贴图的强度。

1
2
[NoScaleOffset] _NormalMap("Normals", 2D) = "bump" {}
_NormalScale("Normal Scale", Range(0, 1)) = 1
法线贴图和缩放
法线贴图和缩放
法线贴图和缩放

存储法线信息最直接的方式是如上所述——XYZ 在 RGB 通道中——但这不是最有效的方式。如果我们假设法线向量始终指向上而不是向下,我们可以省略向上分量并从另外两个派生。那些通道可以存储在压缩纹理格式中,以最小化精度损失。XY 存储在 RG 或 AG 中,取决于纹理格式。这会改变纹理的外观,但 Unity 编辑器只显示原始贴图的预览和缩略图。

法线贴图是否改变取决于目标平台。如果贴图没有改变,则定义 UNITY_NO_DXT5nm。如果是这样,我们可以使用 UnpackNormalRGB 函数来转换采样的法线数据,否则我们可以使用 UnpackNormalmapRGorAG。两者都有采样和缩放参数,定义在 Core RP Library 的 Packing 文件中。在 Common 中添加一个使用这些函数来解码法线数据的函数。

1
2
3
4
5
6
7
8
9
10
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/SpaceTransforms.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Packing.hlsl"
...
float3 DecodeNormal (float4 sample, float scale) {
#if defined(UNITY_NO_DXT5nm)
    return UnpackNormalRGB(sample, scale);
#else
    return UnpackNormalmapRGorAG(sample, scale);
#endif
}

这些 Unity 函数返回归一化向量的情况已不再适用于 Unity 2022,所以我们自己归一化它们。

1
2
3
4
5
6
7
float3 DecodeNormal (float4 sample, float scale) {
#if defined(UNITY_NO_DXT5nm)
    return normalize(UnpackNormalRGB(sample, scale));
#else
    return normalize(UnpackNormalmapRGorAG(sample, scale));
#endif
}

现在将法线贴图、法线缩放和一个 GetNormalTS 函数添加到 LitInput 并检索和解码法线向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
TEXTURE2D(_NormalMap);
...
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
    ...
    UNITY_DEFINE_INSTANCED_PROP(float, _NormalScale)
    UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
...
float3 GetNormalTS (float2 baseUV) {
    float4 map = SAMPLE_TEXTURE2D(_NormalMap, sampler_BaseMap, baseUV);
    float scale = INPUT_PROP(_NormalScale);
    float3 normal = DecodeNormal(map, scale);
    return normal;
}

切线空间

因为纹理包裹着几何体,它们在对象空间和世界空间中的方向并不统一。因此,存储法线的空间会弯曲以匹配几何体的表面。唯一的常量是该空间与表面相切,这就是为什么它被称为切线空间。该空间的 Y 向上轴与表面法线匹配。除此之外,它必须有一个与表面相切的 X 向右轴。如果我们有这两个,我们就可以生成 Z 向前轴。

因为切线空间的 X 轴不是常量,它必须作为网格顶点数据的一部分定义。它存储为四分量切线向量。它的 XYZ 分量定义对象空间中的轴。它的 W 分量是 −1 或 1,用于控制 Z 轴指向的方向。这用于翻转具有双边对称性的网格的法线贴图——大多数动物都有——所以同一贴图可以用于网格的两侧,将所需的纹理大小减半。

因此,如果我们有世界空间法线和切线向量,我们可以构建从切线空间到世界空间的转换矩阵。我们可以使用现有的 CreateTangentToWorld 函数来完成这一点,将法线、切线 XYZ 和切线 W 作为参数传递。然后我们可以使用切线空间法线和转换矩阵作为参数调用 TransformTangentToWorld。在 Common 中添加一个执行所有这些的函数。

1
2
3
4
5
float3 NormalTangentToWorld (float3 normalTS, float3 normalWS, float4 tangentWS) {
    float3x3 tangentToWorld =
        CreateTangentToWorld(normalWS, tangentWS.xyz, tangentWS.w);
    return TransformTangentToWorld(normalTS, tangentToWorld);
}

接下来,在 LitPass 中将对象空间切线向量添加到 Attributes,将世界空间切线添加到 Varyings。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Attributes {
    float3 positionOS : POSITION;
    float3 normalOS : NORMAL;
    float4 tangentOS : TANGENT;
    ...
};

struct Varyings {
    float4 positionCS : SV_POSITION;
    float3 positionWS : VAR_POSITION;
    float3 normalWS : VAR_NORMAL;
    float4 tangentWS : VAR_TANGENT;
    ...
};

切线向量的 XYZ 部分可以通过在 LitPassVertex 中调用 TransformObjectToWorldDir 转换为世界空间。

1
2
3
output.normalWS = TransformObjectToWorldNormal(input.normalOS);
output.tangentWS =
    float4(TransformObjectToWorldDir(input.tangentOS.xyz), input.tangentOS.w);

最后,我们通过在 LitPassFragment 中调用 NormalTangentToWorld 来获取最终映射的法线。

1
2
3
surface.normal = NormalTangentToWorld(
    GetNormalTS(input.baseUV), input.normalWS, input.tangentWS
);
法线映射的球体
法线映射的球体

用于阴影偏移的插值法线

扰动法线向量适用于照亮表面,但我们也使用片段法线来偏置阴影采样。我们应该为此使用原始表面法线。所以为它添加一个字段到 Surface。

1
2
3
4
5
6
struct Surface {
    float3 position;
    float3 normal;
    float3 interpolatedNormal;
    ...
};

在 LitPassFragment 中分配法线向量。在这种情况下,我们通常可以跳过归一化向量,因为大多数网格的顶点法线每个三角形弯曲得不会对阴影偏置产生负面影响。

1
2
3
4
surface.normal = NormalTangentToWorld(
    GetNormalTS(input.baseUV), input.normalWS, input.tangentWS
);
surface.interpolatedNormal = input.normalWS;

然后在 GetCascadedShadow 中使用这个向量。

1
2
3
4
5
6
7
8
9
10
11
12
13
float GetCascadedShadow (
    DirectionalShadowData directional, ShadowData global, Surface surfaceWS
) {
    float3 normalBias = surfaceWS.interpolatedNormal *
        (directional.normalBias * _CascadeData[global.cascadeIndex].y);
    ...
    if (global.cascadeBlend < 1.0) {
        normalBias = surfaceWS.interpolatedNormal *
            (directional.normalBias * _CascadeData[global.cascadeIndex + 1].y);
    ...
    }
    return shadow;
}

细节法线

我们也可以为细节包含一个法线贴图。尽管 HDRP 在单个贴图中将细节法线与漫反射和平滑度结合,我们将使用单独的纹理。将导入的纹理转换为法线贴图并启用 Fadeout Mip Maps,使其像其他细节一样淡出。

细节法线贴图
细节法线贴图

为贴图添加 shader 属性,再次为法线缩放添加属性。

1
2
3
4
5
_DetailMap("Details", 2D) = "linearGrey" {}
[NoScaleOffset] _DetailNormalMap("Detail Normals", 2D) = "bump" {}
_DetailAlbedo("Detail Albedo", Range(0, 1)) = 1
_DetailSmoothness("Detail Smoothness", 0, 1) = 1
_DetailNormalScale("Detail Normal Scale", Range(0, 1)) = 1
细节法线属性;设置为半强度
细节法线属性;设置为半强度

将这些添加到输入数据。

1
2
3
4
5
6
7
8
9
TEXTURE2D(_DetailMap);
TEXTURE2D(_DetailNormalMap);
SAMPLER(sampler_DetailMap);
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
    ...
    UNITY_DEFINE_INSTANCED_PROP(float, _DetailSmoothness)
    UNITY_DEFINE_INSTANCED_PROP(float, _DetailNormalScale)
    UNITY_DEFINE_INSTANCED_PROP(float, _NormalScale)
    UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

通过添加细节 UV 参数调整 GetNormalTS 并采样细节贴图。在这种情况下,我们可以通过将遮罩因子化到细节法线强度中来应用遮罩。之后,我们必须组合两个法线,我们可以通过使用原始和细节法线调用 BlendNormalRNM 来完成。这个函数围绕基础法线旋转细节法线。

1
2
3
4
5
6
7
8
9
10
float3 GetNormalTS (float2 baseUV, float2 detailUV = 0.0) {
    float4 map = SAMPLE_TEXTURE2D(_NormalMap, sampler_BaseMap, baseUV);
    float scale = INPUT_PROP(_NormalScale);
    float3 normal = DecodeNormal(map, scale);
    map = SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailMap, detailUV);
    scale = INPUT_PROP(_DetailNormalScale) * GetMask(baseUV).b;
    float3 detail = DecodeNormal(map, scale);
    normal = BlendNormalRNM(normal, detail);
    return normal;
}

最后,将细节 UV 传递给 GetNormalTS。

1
2
3
surface.normal = NormalTangentToWorld(
    GetNormalTS(input.baseUV, input.detailUV), input.normalWS, input.tangentWS
);

可选贴图

并非每个材质都需要我们目前支持的所有贴图。留空贴图意味着结果不会改变,但 Shader 仍然完成所有工作,使用默认纹理。我们可以通过添加一些 shader 特性来避免不必要的 work,以控制哪些贴图被 shader 使用。Unity 的 shader 会根据编辑器中分配了哪些贴图自动执行此操作,但我们将通过显式开关来控制。

法线贴图

我们从法线贴图开始,这是最昂贵的功能。添加一个与适当关键字关联的开关 shader 属性。

1
2
3
[Toggle(_NORMAL_MAP)] _NormalMapToggle ("Normal Map", Float) = 0
[NoScaleOffset] _NormalMap("Normals", 2D) = "bump" {}
_NormalScale("Normal Scale", Range(0, 1)) = 1

将匹配的 shader feature pragma 添加到 CustomLit pass。其他 pass 都不需要映射法线,所以不应该获得该特性。

1
#pragma shader_feature _NORMAL_MAP

在 LitPassFragment 中,根据关键字使用切线空间法线或只是归一化插值法线。在后一种情况下,我们也可以为插值法线使用归一化版本。

1
2
3
4
5
6
7
8
9
10
#if defined(_NORMAL_MAP)
surface.normal = NormalTangentToWorld(
    GetNormalTS(input.baseUV, input.detailUV),
    input.normalWS, input.tangentWS
);
surface.interpolatedNormal = input.normalWS;
#else
surface.normal = normalize(input.normalWS);
surface.interpolatedNormal = surface.normal;
#endif
细节法线
细节法线

此外,如果可能的话,从 Varyings 中省略切线向量。我们不需要从 Attributes 中省略它,因为如果 我们不使用它,它会自动被忽略。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Varyings {
    ...
#if defined(_NORMAL_MAP)
    float4 tangentWS : VAR_TANGENT;
#endif
    ...
};

Varyings LitPassVertex (Attributes input) {
    ...
#if defined(_NORMAL_MAP)
    output.tangentWS = float4(
        TransformObjectToWorldDir(input.tangentOS.xyz), input.tangentOS.w
    );
#endif
    ...
}

输入配置

此时,我们应该重新考虑如何将数据传递给 LitInput 的 getter 函数。我们最终可能会使用或不使用多个事物的任意组合,我们必须以某种方式传达这一点。让我们引入一个 InputConfig 结构体,最初捆绑基础和细节 UV 坐标。还要创建一个方便的 GetInputConfig 函数,给定基础 UV 和可选细节 UV 返回配置。

1
2
3
4
5
6
7
8
9
10
11
struct InputConfig {
    float2 baseUV;
    float2 detailUV;
};

InputConfig GetInputConfig (float2 baseUV, float2 detailUV = 0.0) {
    InputConfig c;
    c.baseUV = baseUV;
    c.detailUV = detailUV;
    return c;
}

现在调整所有 LitInput 函数(TransformBaseUV 和 TransformDetailUV 除外),使它们有一个配置参数。我只展示 GetBase 的更改。

1
2
3
4
5
6
7
float4 GetBase (InputConfig c) {
    float4 map = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, c.baseUV);
    float4 color = INPUT_PROP(_BaseColor);
    float detail = GetDetail(c).r * INPUT_PROP(_DetailAlbedo);
    float mask = GetMask(c).b;
    ...
}

然后调整 LitPassFragment 使其使用新的配置方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
InputConfig config = GetInputConfig(input.baseUV, input.detailUV);
float4 base = GetBase(config);
#if defined(_CLIPPING)
clip(base.a - GetCutoff(config));
#endif

Surface surface;
surface.position = input.positionWS;
#if defined(_NORMAL_MAP)
surface.normal = NormalTangentToWorld(
    GetNormalTS(config), input.normalWS, input.tangentWS
);
#else
surface.normal = normalize(input.normalWS);
#endif
...
surface.metallic = GetMetallic(config);
surface.occlusion = GetOcclusion(config);
surface.smoothness = GetSmoothness(config);
surface.fresnelStrength = GetFresnel(config);
...
color += GetEmission(config);

调整其他 pass——MetaPass、ShadowCasterPass 和 UnlitPass——也使用新方法。这意味着我们也必须让 UnlitPass 使用新方法。

可选遮罩贴图

接下来,让我们通过在 InputConfig 中添加一个布尔值来使遮罩贴图可选,默认设置为 false。

1
2
3
4
5
6
7
8
9
10
11
12
13
struct InputConfig {
    float2 baseUV;
    float2 detailUV;
    bool useMask;
};

InputConfig GetInputConfig (float2 baseUV, float2 detailUV = 0.0) {
    InputConfig c;
    c.baseUV = baseUV;
    c.detailUV = detailUV;
    c.useMask = false;
    return c;
}

我们可以通过在 GetMask 中简单返回 1 来避免采样遮罩。这假设遮罩开关是常量,所以不会在 shader 中导致分支。

1
2
3
4
5
6
float4 GetMask (InputConfig c) {
    if (c.useMask) {
        return SAMPLE_TEXTURE2D(_MaskMap, sampler_BaseMap, c.baseUV);
    }
    return 1.0;
}

为其添加一个开关属性到我们的 shader。

1
2
[Toggle(_MASK_MAP)] _MaskMapToggle ("Mask Map", Float) = 0
[NoScaleOffset] _MaskMap("Mask (MODS)", 2D) = "white" {}

以及 CustomLit pass 中相关的 pragma。

1
#pragma shader_feature _MASK_MAP

现在只在需要时在 LitPassFragment 中开启遮罩。

1
2
3
4
InputConfig config = GetInputConfig(input.baseUV, input.detailUV);
#if defined(_MASK_MAP)
config.useMask = true;
#endif

可选细节

使用相同的方法,为细节添加一个开关到 InputConfig,再次默认禁用。

1
2
3
4
5
6
7
8
9
10
struct InputConfig {
    ...
    bool useDetail;
};

InputConfig GetInputConfig (float2 baseUV, float2 detailUV = 0.0) {
    ...
    c.useDetail = false;
    return c;
}

只在需要时在 GetDetail 中采样细节贴图,否则返回零。

1
2
3
4
5
6
7
float4 GetDetail (InputConfig c) {
    if (c.useDetail) {
        float4 map = SAMPLE_TEXTURE2D(_DetailMap, sampler_DetailMap, c.detailUV);
        return map * 2.0 - 1.0;
    }
    return 0.0;
}
可选法线贴图已启用
可选法线贴图已启用

这避免了采样细节贴图,但细节的合并仍然发生。要阻止这一点,还要在 GetBase 中跳过相关代码。

1
2
3
4
5
6
7
if (c.useDetail) {
    float detail = GetDetail(c).r * INPUT_PROP(_DetailAlbedo);
    float mask = GetMask(c).b;
    map.rgb =
        lerp(sqrt(map.rgb), detail < 0.0 ? 0.0 : 1.0, abs(detail) * mask);
    map.rgb *= map.rgb;
}

在 GetSmoothness 中也是如此。

1
2
3
4
5
6
if (c.useDetail) {
    float detail = GetDetail(c).b * INPUT_PROP(_DetailSmoothness);
    float mask = GetMask(c).b;
    smoothness =
        lerp(smoothness, detail < 0.0 ? 0.0 : 1.0, abs(detail) * mask);
}

在 GetNormalTS 中也是如此。

1
2
3
4
5
6
if (c.useDetail) {
    map = SAMPLE_TEXTURE2D(_DetailNormalMap, sampler_DetailMap, c.detailUV);
    scale = INPUT_PROP(_DetailNormalScale) * GetMask(c).b;
    float3 detail = DecodeNormal(map, scale);
    normal = BlendNormalRNM(normal, detail);
}

然后为细节添加一个开关属性到 shader。

1
2
[Toggle(_DETAIL_MAP)] _DetailMap", Float) =Toggle ("Detail Maps 0
_DetailMap("Details", 2D) = "linearGrey" {}

再次在 CustomLit 中附带一个 shader feature。

1
#pragma shader_feature _DETAIL_MAP

现在我们只需要在定义了相关关键字时才在 Varyings 中包含细节 UV。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Varyings {
    ...
#if defined(_DETAIL_MAP)
    float2 detailUV : VAR_DETAIL_UV;
#endif
    ...
};

Varyings LitPassVertex (Attributes input) {
    ...
#if defined(_DETAIL_MAP)
    output.detailUV = TransformDetailUV(input.baseUV);
#endif
    return output;
}

最后,只在需要时在 LitPassFragment 中包含细节。

1
2
3
4
5
6
7
8
InputConfig config = GetInputConfig(input.baseUV);
#if defined(_MASK_MAP)
config.useMask = true;
#endif
#if defined(_DETAIL_MAP)
config.detailUV = input.detailUV;
config.useDetail = true;
#endif

下一个教程是Point and Spot Lights

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