Post

三平面映射(翻译二十七)

三平面映射(翻译二十七)
  • 移除对 UV 和切线的依赖
  • 支持通用曲面方法
  • 使用平面投影
  • 混合三种映射方式

1. 无 UV 坐标的纹理化

通常进行纹理映射的方法是使用网格中每个顶点存储的 UV 坐标。但这并不是唯一的方法。有时,根本没有可用的 UV 坐标。例如,在处理任意形状的程序化几何体时。在运行时创建地形或洞穴系统时,通常无法为适当的纹理展开生成 UV 坐标。在这种情况下,我们必须使用另一种方法将纹理映射到表面上。其中一种方法就是三平面映射 (Triplanar Mapping)

到目前为止,我们一直假设 UV 坐标是可用的。我们的 My Lighting InputMy Lighting 着色器包含文件都依赖于它们。虽然我们可以创建不依赖于顶点 UV 的替代方案,但如果能让当前的文件在有无 UV 的情况下都能工作会更方便。这需要一些改动。

我们将保持当前的方法作为默认方案,但在定义了 NO_DEFAULT_UV 时切换到无 UV 模式。

1.1 不使用默认 UV

当网格数据不包含 UV 时,我们就没有 UV 可以从顶点传递到片段程序。因此,让 My Lighting Input 中的 UV 插值器的存在取决于 NO_DEFAULT_UV

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct InterpolatorsVertex {
    ...
    #if !defined(NO_DEFAULT_UV)
        float4 uv : TEXCOORD0;
    #endif
    ...
};

struct Interpolators {
    ...
    #if !defined(NO_DEFAULT_UV)
        float4 uv : TEXCOORD0;
    #endif
    ...
};

有多个函数假设插值器始终包含 UV,因此我们必须确保它们能继续工作和编译。我们将通过在插值器声明下方引入一个新的 GetDefaultUV 函数来实现这一点。当没有 UV 可用时,它将简单地返回零,否则返回常规 UV。

我们还将允许通过定义 UV_FUNCTION 来提供替代方法,以防万一。这类似于 ALBEDO_FUNCTION,但覆盖必须在包含 My Lighting Input 之前定义。

1
2
3
4
5
6
7
8
9
10
11
float4 GetDefaultUV (Interpolators i) {
    #if defined(NO_DEFAULT_UV)
        return float4(0, 0, 0, 0);
    #else
        return i.uv;
    #endif
}

#if !defined(UV_FUNCTION)
    #define UV_FUNCTION GetDefaultUV
#endif

现在我们可以将所有使用 i.uv 的地方更改为 UV_FUNCTION(i)。我只展示了 GetDetailMask 的更改,但它适用于所有 getter 函数。

1
2
3
4
5
6
7
float GetDetailMask (Interpolators i) {
    #if defined (_DETAIL_MASK)
        return tex2D(_DetailMask, UV_FUNCTION(i).xy).a;
    #else
        return 1;
    #endif
}

接下来是 My Lighting,我们必须确保在没有 UV 可用时跳过顶点程序中所有与 UV 相关的操作。这适用于纹理坐标变换,也适用于默认的顶点位移方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
InterpolatorsVertex MyVertexProgram (VertexData v) {
    ...
    #if !defined(NO_DEFAULT_UV)
        i.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
        i.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);

        #if VERTEX_DISPLACEMENT
            float displacement = tex2Dlod(_DisplacementMap, float4(i.uv.xy, 0, 0)).g;
            displacement = (displacement - 0.5) * _DisplacementStrength;
            v.normal = normalize(v.normal);
            v.vertex.xyz += v.normal * displacement;
        #endif
    #endif
    ...
}

视差效果(Parallax effect)也依赖于默认 UV,因此在 UV 不可用时跳过它。

1
2
3
4
5
void ApplyParallax (inout Interpolators i) {
    #if defined(_PARALLAX_MAP) && !defined(NO_DEFAULT_UV)
        ...
    #endif
}

1.2 收集表面属性

没有 UV,必须有另一种方法来确定用于光照的表面属性。为了使其尽可能通用,我们的包含文件不应该关心这些属性是如何获得的。我们只需要一种通用的方法来提供表面属性。我们可以使用类似于 Unity 表面着色器(Surface Shaders)的方法,依靠一个函数来设置所有表面属性。

创建一个新的 MySurface.cginc 包含文件。在其中定义一个 SurfaceData 结构体,包含光照所需的所有表面属性:反照率(albedo)、发射(emission)、法线(normal)、Alpha、金属度(metallic)、遮蔽(occlusion)和光滑度(smoothness)。

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

struct SurfaceData {
    float3 albedo, emission, normal;
    float alpha, metallic, occlusion, smoothness;
};

#endif

我们把它放在一个单独的文件中,这样其他代码就可以在包含任何其他文件之前使用它。但我们的文件也会依赖它,所以把它包含在 My Lighting Input 中。

1
2
3
#include "UnityPBSLighting.cginc"
#include "AutoLight.cginc"
#include "MySurface.cginc"

My Lighting 中,在 MyFragmentProgram 的开头、ApplyParallax 之后和使用 Alpha 之前,使用默认函数设置一个新的 SurfaceData surface 变量。然后更改 Alpha 代码以依赖 surface.alpha 而不是调用 GetAlpha。同时移动 InitializeFragmentNormal,以便在配置表面之前处理法线向量。

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
FragmentOutput MyFragmentProgram (Interpolators i) {
    UNITY_SETUP_INSTANCE_ID(i);
    #if defined(LOD_FADE_CROSSFADE)
        UnityApplyDitherCrossFade(i.vpos);
    #endif

    ApplyParallax(i);

    InitializeFragmentNormal(i);

    SurfaceData surface;
    surface.normal = i.normal;
    surface.albedo = ALBEDO_FUNCTION(i);
    surface.alpha = GetAlpha(i);
    surface.emission = GetEmission(i);
    surface.metallic = GetMetallic(i);
    surface.occlusion = GetOcclusion(i);
    surface.smoothness = GetSmoothness(i);

    float alpha = surface.alpha;
    #if defined(_RENDERING_CUTOUT)
        clip(alpha - _Cutoff);
    #endif
    
    // InitializeFragmentNormal(i);
    ...
}

现在在确定片段颜色时,依赖 surface 而不是再次调用 getter 函数。

1
2
3
4
5
6
7
8
9
10
11
float3 albedo = DiffuseAndSpecularFromMetallic(
    surface.albedo, surface.metallic, specularTint, oneMinusReflectivity
);
...
float4 color = UNITY_BRDF_PBS(
    albedo, specularTint,
    oneMinusReflectivity, surface.smoothness,
    i.normal, viewDir,
    CreateLight(i), CreateIndirectLight(i, viewDir)
);
color.rgb += surface.emission;

以及在填充延迟渲染的 G-buffers 时:

1
2
3
4
5
6
7
8
9
10
11
12
#if defined(DEFERRED_PASS)
    #if !defined(UNITY_HDR_ON)
        color.rgb = exp2(-color.rgb);
    #endif
    output.gBuffer0.rgb = albedo;
    output.gBuffer0.a = surface.occlusion;
    output.gBuffer1.rgb = specularTint;
    output.gBuffer1.a = surface.smoothness;
    output.gBuffer2 = float4(i.normal * 0.5 + 0.5, 1);
    output.gBuffer3 = color;
    ...
#endif

CreateIndirectLight 函数也使用了 getter 函数,因此给它添加一个 SurfaceData 参数并改用该参数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
UnityIndirect CreateIndirectLight (
    Interpolators i, float3 viewDir, SurfaceData surface
) {
    ...
    #if defined(FORWARD_BASE_PASS) || defined(DEFERRED_PASS)
        ...
        float3 reflectionDir = reflect(-viewDir, i.normal);
        Unity_GlossyEnvironmentData envData;
        envData.roughness = 1 - surface.smoothness;
        ...
        float occlusion = surface.occlusion;
        ...
    #endif
    return indirectLight;
}

然后在 MyFragmentProgram 中为其调用添加 surface 作为参数。

1
CreateLight(i), CreateIndirectLight(i, viewDir, surface)

1.3 定制表面

为了能够更改表面数据的获取方式,我们将再次允许定义自定义函数。这个函数需要输入才能工作。默认情况下,那是 UV 坐标,主 UV 和细节 UV 都打包在一个 float4 中。替代输入可以是位置和法线向量。在我们的 Surface 文件中添加一个包含所有这些输入的 SurfaceParameters 结构体。

1
2
3
4
5
6
7
8
9
struct SurfaceData {
    float3 albedo, emission, normal;
    float alpha, metallic, occlusion, smoothness;
};

struct SurfaceParameters {
    float3 normal, position;
    float4 uv;
};

回到 My Lighting,调整 MyFragmentProgram,使其在定义了 SURFACE_FUNCTION 时使用不同的方式设置表面数据。在这种情况下,用法线向量填充 surface 并将所有其他值设置为默认值。然后创建表面参数并调用自定义表面函数。它的参数是 surface(作为 inout 参数)和参数结构体。

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
SurfaceData surface;
#if defined(SURFACE_FUNCTION)
    surface.normal = i.normal;
    surface.albedo = 1;
    surface.alpha = 1;
    surface.emission = 0;
    surface.metallic = 0;
    surface.occlusion = 1;
    surface.smoothness = 0.5;

    SurfaceParameters sp;
    sp.normal = i.normal;
    sp.position = i.worldPos.xyz;
    sp.uv = UV_FUNCTION(i);

    SURFACE_FUNCTION(surface, sp);
#else
    surface.normal = i.normal;
    surface.albedo = ALBEDO_FUNCTION(i);
    surface.alpha = GetAlpha(i);
    surface.emission = GetEmission(i);
    surface.metallic = GetMetallic(i);
    surface.occlusion = GetOcclusion(i);
    surface.smoothness = GetSmoothness(i);
#endif

由于 SURFACE_FUNCTION 可能会更改表面法线,之后将其赋回给 i.normal。这样我们就不需要更改所有使用 i.normal 的代码。

1
2
3
4
5
6
#if defined(SURFACE_FUNCTION)
    ...
#else
    ...
#endif
i.normal = surface.normal;

1.4 无切线空间

请注意,与 Unity 的表面着色器方法不同,我们是在世界空间(而非切线空间)中使用法线向量。如果我们想在 SURFACE_FUNCTION 中使用切线空间法线映射,那么我们必须自己显式地执行此操作。我们还可以支持更多关于法线在调用 SURFACE_FUNCTION 前后应如何处理的配置选项,但我们在本教程中不会这样做。

我们要做的,是在不使用切线时能够关闭默认的切线空间法线映射方法。这样在不使用切线时可以节省工作。我们通过仅在默认法线映射或视差映射处于活动状态时打开切线空间来实现这一点。在 My Lighting Input 中用一个方便的 REQUIRES_TANGENT_SPACE 宏来表示。

1
2
3
4
5
6
#if defined(_NORMAL_MAP) || defined(_DETAIL_NORMAL_MAP) || defined(_PARALLAX_MAP)
    #define REQUIRES_TANGENT_SPACE 1
    #define TESSELLATION_TANGENT 1
#endif
#define TESSELLATION_UV1 1
#define TESSELLATION_UV2 1

现在我们只需在需要时包含切向和副法线向量插值器。

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
struct InterpolatorsVertex {
    ...
    #if REQUIRES_TANGENT_SPACE
        #if defined(BINORMAL_PER_FRAGMENT)
            float4 tangent : TEXCOORD2;
        #else
            float3 tangent : TEXCOORD2;
            float3 binormal : TEXCOORD3;
        #endif
    #endif
    ...
};

struct Interpolators {
    ...
    #if REQUIRES_TANGENT_SPACE
        #if defined(BINORMAL_PER_FRAGMENT)
            float4 tangent : TEXCOORD2;
        #else
            float3 tangent : TEXCOORD2;
            float3 binormal : TEXCOORD3;
        #endif
    #endif
    ...
};

My Lighting 中,我们可以跳过在 MyVertexProgram 中设置这些向量。

1
2
3
4
5
6
7
8
9
10
11
12
InterpolatorsVertex MyVertexProgram (VertexData v) {
    ...
    #if REQUIRES_TANGENT_SPACE
        #if defined(BINORMAL_PER_FRAGMENT)
            i.tangent = float4(UnityObjectToWorldDir(v.tangent.xyz), v.tangent.w);
        #else
            i.tangent = UnityObjectToWorldDir(v.tangent.xyz);
            i.binormal = CreateBinormal(i.normal, i.tangent, v.tangent.w);
        #endif
    #endif
    ...
}

在没有切线空间的情况下,InitializeFragmentNormal 简化为仅对插值法线进行归一化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void InitializeFragmentNormal(inout Interpolators i) {
    #if REQUIRES_TANGENT_SPACE
        float3 tangentSpaceNormal = GetTangentSpaceNormal(i);
        #if defined(BINORMAL_PER_FRAGMENT)
            float3 binormal = CreateBinormal(i.normal, i.tangent.xyz, i.tangent.w);
        #else
            float3 binormal = i.binormal;
        #endif
        
        i.normal = normalize(
            tangentSpaceNormal.x * i.tangent +
            tangentSpaceNormal.y * binormal +
            tangentSpaceNormal.z * i.normal
        );
    #else
        i.normal = normalize(i.normal);
    #endif
}

1.5 三平面着色器

我们所有的着色器仍然有效,但现在可以使用我们的包含文件而不使用切线空间,并使用替代表面数据。让我们创建一个新的着色器来利用这一点。首先,创建一个新的 MyTriplanarMapping.cginc 包含文件。让它定义 NO_DEFAULT_UV,然后包含 Surface.cginc。实际上,由于我们将使用 My Lighting Input 中已经定义的 _MainTex 属性,请改为包含该文件。然后创建一个带有 inout SurfaceData surface 参数和常规 SurfaceParameters parameters 参数的 MyTriplanarSurfaceFunction。目前,只需让它使用法线来设置反照率。将此函数定义为 SURFACE_FUNCTION

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#if !defined(MY_TRIPLANAR_MAPPING_INCLUDED)
#define MY_TRIPLANAR_MAPPING_INCLUDED

#define NO_DEFAULT_UV

#include "My Lighting Input.cginc"

void MyTriPlanarSurfaceFunction (
    inout SurfaceData surface, SurfaceParameters parameters
) {
    surface.albedo = parameters.normal * 0.5 + 0.5;
}

#define SURFACE_FUNCTION MyTriPlanarSurfaceFunction

#endif

创建一个使用此包含文件而不是 My Lighting Input 的新着色器。我们将制作一个不带透明度的最小化着色器,支持通常的渲染管线,外加雾效和实例化。这是带有前向基础(forward base)和附加(additive)通道的着色器。

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
29
30
31
32
33
34
35
Shader "Custom/Triplanar Mapping" {
    Properties {
        _MainTex ("Albedo", 2D) = "white" {}
    }
    SubShader {
        Pass {
            Tags { "LightMode" = "ForwardBase" }
            CGPROGRAM
            #pragma target 3.0
            #pragma multi_compile_fwdbase
            #pragma multi_compile_fog
            #pragma multi_compile_instancing
            #pragma vertex MyVertexProgram
            #pragma fragment MyFragmentProgram
            #define FORWARD_BASE_PASS
            #include "MyTriplanarMapping.cginc"
            #include "My Lighting.cginc"
            ENDCG
        }
        Pass {
            Tags { "LightMode" = "ForwardAdd" }
            Blend One One
            ZWrite Off
            CGPROGRAM
            #pragma target 3.0
            #pragma multi_compile_fwdadd_fullshadows
            #pragma multi_compile_fog
            #pragma vertex MyVertexProgram
            #pragma fragment MyFragmentProgram
            #include "MyTriplanarMapping.cginc"
            #include "My Lighting.cginc"
            ENDCG
        }
    }
}

这里是延迟渲染(deferred)和阴影投射(shadow caster)通道。请注意,阴影通道不需要特殊处理,因为它不关心不透明几何体的表面属性。我们目前还没有添加光照贴图(lightmapping)支持,所以目前没有 meta 通道。

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
29
30
31
Shader "Custom/Triplanar Mapping" {
    ...
    SubShader {
        ...
        Pass {
            Tags { "LightMode" = "Deferred" }
            CGPROGRAM
            #pragma target 3.0
            #pragma exclude_renderers nomrt
            #pragma multi_compile_prepassfinal
            #pragma multi_compile_instancing
            #pragma vertex MyVertexProgram
            #pragma fragment MyFragmentProgram
            #define DEFERRED_PASS
            #include "MyTriplanarMapping.cginc"
            #include "My Lighting.cginc"
            ENDCG
        }
        Pass {
            Tags { "LightMode" = "ShadowCaster" }
            CGPROGRAM
            #pragma target 3.0
            #pragma multi_compile_shadowcaster
            #pragma multi_compile_instancing
            #pragma vertex MyShadowVertexProgram
            #pragma fragment MyShadowFragmentProgram
            #include "My Shadows.cginc"
            ENDCG
        }
    }
}

使用我们的新着色器创建一个材质并尝试一下。我使用了旧的测试纹理作为材质的主纹理,虽然此时它还没有被用到。

使用法线作为反照率的三平面映射材质
使用法线作为反照率的三平面映射材质
使用法线作为反照率的三平面映射材质

2. 三平面纹理化

当顶点 UV 坐标不可用时,我们如何执行纹理映射?我们必须使用替代方案。唯一可行的方法是使用世界位置——或者可能是对象空间位置——作为纹理映射的替代 UV 坐标来源。

2.1 基于位置的纹理映射

片段的世界位置是一个 3D 向量,但常规纹理映射是在 2D 中完成的。所以我们必须选择两个维度作为 UV 坐标,这意味着我们将纹理映射到 3D 空间的一个平面上。最明显的选择是使用 XY 坐标。

1
surface.albedo = tex2D(_MainTex, parameters.position.xy);
使用位置 XY 作为 UV 坐标
使用位置 XY 作为 UV 坐标

使用 3D 纹理呢?

这也是可能的,但 3D 纹理需要更多的存储空间,并且很难做得好看。

结果是我们看到纹理沿 Z 轴投影。但这并不是唯一可能的方向。我们还可以通过改用 XZ 坐标来沿 Y 轴投影。这对应于纹理化地形时常用的平面纹理映射。

1
surface.albedo = tex2D(_MainTex, parameters.position.xz);
使用位置 XZ 作为 UV 坐标
使用位置 XZ 作为 UV 坐标

第三个选项是通过使用 YZ 坐标沿 X 投影。

1
surface.albedo = tex2D(_MainTex, parameters.position.yz);
使用位置 YZ 作为 UV 坐标
使用位置 YZ 作为 UV 坐标

但是当我们使用 YZ 时,纹理最终会旋转 90°。为了保持预期的方向,我们必须改用 ZY。

1
surface.albedo = tex2D(_MainTex, parameters.position.zy);
使用位置 ZY 作为 UV 坐标
使用位置 ZY 作为 UV 坐标

2.2 合并所有三个映射

当表面主要与投影轴对齐时,单平面映射效果很好,但在不对齐时看起来很糟糕。当沿一个轴的效果不好时,沿另一个轴的效果可能会更好。因此,支持所有三个映射是有用的,这需要我们提供三对不同的 UV 坐标。

让我们保持确定这些 UV 坐标的逻辑独立。创建一个包含所有三个轴坐标对的 TriplanarUV 结构体。然后制作一个 GetTriplanarUV 函数,根据表面参数设置 UV。

1
2
3
4
5
6
7
8
9
10
11
12
struct TriplanarUV {
    float2 x, y, z;
};

TriplanarUV GetTriplanarUV (SurfaceParameters parameters) {
    TriplanarUV triUV;
    float3 p = parameters.position;
    triUV.x = p.zy;
    triUV.y = p.xz;
    triUV.z = p.xy;
    return triUV;
}

MyTriPlanarSurfaceFunction 中使用此函数,并对所有三个投影进行采样。最终的反照率变为它们的平均值。

1
2
3
4
5
6
7
8
9
10
void MyTriPlanarSurfaceFunction (
    inout SurfaceData surface, SurfaceParameters parameters
) {
    TriplanarUV triUV = GetTriplanarUV(parameters);
    float3 albedoX = tex2D(_MainTex, triUV.x).rgb;
    float3 albedoY = tex2D(_MainTex, triUV.y).rgb;
    float3 albedoZ = tex2D(_MainTex, triUV.z).rgb;
    surface.albedo = (albedoX + albedoY + albedoZ) / 3;
    ...
}
对三个映射取平均值
对三个映射取平均值

2.3 基于法线的混合

我们现在总是得到最好的投影,但也得到了另外两个。我们不能只使用最好的那个,因为在“最好”突然发生变化的地方会出现接缝。但我们可以做的是在它们之间进行平滑混合。

首选的映射是与表面方向最一致的映射,这由表面法线指示。因此,我们可以用法线来定义所有三个投影的权重。我们必须使用法线向量的绝对值,因为表面可能面向负方向。此外,权重的总和必须为 1,因此我们必须通过除以它们的总和来对它们进行归一化。创建一个新函数来计算这些权重。

1
2
3
4
float3 GetTriplanarWeights (SurfaceParameters parameters) {
    float3 triW = abs(parameters.normal);
    return triW / (triW.x + triW.y + triW.z);
}

现在我们可以通过权重来调制每个映射的贡献。

1
2
3
4
5
6
7
8
void MyTriPlanarSurfaceFunction (
    inout SurfaceData surface, SurfaceParameters parameters
) {
    ...
    float3 triW = GetTriplanarWeights(parameters);
    surface.albedo = albedoX * triW.x + albedoY * triW.y + albedoZ * triW.z;
    ...
}
混合三个映射
混合三个映射

2.4 镜像映射

现在最可能的投影是最强的。在轴对齐的表面上,我们最终只能看到单个映射。轴对齐的立方体在所有面上看起来都不错,除了其中一半最终使用了镜像映射。

纹理在另一面被镜像了
纹理在另一面被镜像了

当纹理被镜像时,并不总是一个问题,但当使用上面带有数字的测试纹理时就很明显了。因此,让我们确保纹理永远不会被镜像。我们通过在适当的时候取负 U 坐标来实现。在 X 映射的情况下,即 normal.x 为负时。同样,对于 Y 投影,当 normal.y 为负时。对于 Z 则相反。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
TriplanarUV GetTriplanarUV (SurfaceParameters parameters) {
    TriplanarUV triUV;
    float3 p = parameters.position;
    triUV.x = p.zy;
    triUV.y = p.xz;
    triUV.z = p.xy;
    if (parameters.normal.x < 0) {
        triUV.x.x = -triUV.x.x;
    }
    if (parameters.normal.y < 0) {
        triUV.y.x = -triUV.y.x;
    }
    if (parameters.normal.z >= 0) {
        triUV.z.x = -triUV.z.x;
    }
    return triUV;
}
不再镜像
不再镜像

请注意,这会在每个映射维度为零的地方产生接缝,但这没关系,因为它们的权重在那里也是零。

2.5 偏移映射

因为我们是在表面上投影三次相同的纹理,最终可能会出现明显的重复。这在球体上可能非常明显。你可以移动它,直到出现如下图所示的纹理对齐。从左到右,你可以看到序列 44, 45, 40, 44, 45, 40,即使完整序列是 40–45。在它下面你可以看到 34, 35, 30, 34, 35, 30。垂直方向你可以看到 44 和 45 重复。

对齐的映射
对齐的映射

我们可以通过偏移投影来消除此类重复。如果我们垂直移动 X 映射 1/2,那么我们就在 X 和 Z 之间消除了它们。同样,如果我们水平移动 X 映射 1/2,那么就在 Y 和 Z 之间消除了它们。X 和 Y 映射不对齐,所以我们不必担心它们。

1
2
3
4
5
6
TriplanarUV GetTriplanarUV (SurfaceParameters parameters) {
    ...
    triUV.x.y += 0.5;
    triUV.z.x += 0.5;
    return triUV;
}
偏移映射
偏移映射

我们使用 1/2 作为偏移量,因为那是最大值。在我们的测试纹理的情况下,它打破了数字序列,但保持了块对齐。如果我们使用的纹理具有三个而不是六个明显的条带,偏移 1/3 效果会更好。通常,三平面映射是针对地形纹理完成的,对于这些纹理,你不需要担心精确对齐。

3. 其他表面属性

除了反照率,还有更多的表面属性可以存储在映射中。例如,对于我们的电路材料,我们还有金属度、遮蔽、光滑度和法线贴图。我们也来支持这些。

仅使用电路反照率贴图
仅使用电路反照率贴图

3.1 MOS 贴图

使用三平面映射时,我们使用三个不同的投影对贴图进行采样。这使着色器中的纹理采样量增加了三倍。为了保持可控性,我们应该旨在最小化每个投影的采样量。我们可以通过在一个贴图中存储多个表面属性来实现。我们的电路材料已经有这样一个贴图,在 R 通道存储金属度,在 G 通道存储遮蔽,在 A 通道存储光滑度。所以这就是一个金属度-遮蔽-光滑度贴图,简称 MOS 贴图。我们将在三平面着色器中依赖这样的 MOS 贴图,因此添加一个属性。

1
2
3
4
Properties {
    _MainTex ("Albedo", 2D) = "white" {}
    [NoTilingOffset] _MOSMap ("MOS", 2D) = "white" {}
}
带有电路 MOS 贴图的材质
带有电路 MOS 贴图的材质

为此贴图添加一个变量——因为它在 My Lighting Input 中没有定义——然后像反照率贴图一样对其采样三次。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
sampler2D _MOSMap;
...
void MyTriPlanarSurfaceFunction (
    inout SurfaceData surface, SurfaceParameters parameters
) {
    TriplanarUV triUV = GetTriplanarUV(parameters);
    float3 albedoX = tex2D(_MainTex, triUV.x).rgb;
    float3 albedoY = tex2D(_MainTex, triUV.y).rgb;
    float3 albedoZ = tex2D(_MainTex, triUV.z).rgb;
    float4 mosX = tex2D(_MOSMap, triUV.x);
    float4 mosY = tex2D(_MOSMap, triUV.y);
    float4 mosZ = tex2D(_MOSMap, triUV.z);
    ...
}

使用三平面权重混合 MOS 数据,然后使用结果设置表面。

1
2
3
4
5
surface.albedo = albedoX * triW.x + albedoY * triW.y + albedoZ * triW.z;
float4 mos = mosX * triW.x + mosY * triW.y + mosZ * triW.z;
surface.metallic = mos.x;
surface.occlusion = mos.y;
surface.smoothness = mos.a;
使用电路 MOS 贴图
使用电路 MOS 贴图

3.2 法线贴图

也添加对法线贴图的支持。我们不能将其打包在另一个贴图中,因此它需要自己的属性。

1
2
3
4
5
Properties {
    _MainTex ("Albedo", 2D) = "white" {}
    [NoTilingOffset] _MOSMap ("MOS", 2D) = "white" {}
    [NoTilingOffset] _NormalMap ("Normals", 2D) = "white" {}
}
带有电路法线贴图的材质
带有电路法线贴图的材质

采样该贴图三次,并为每个轴解包法线。

1
2
3
4
5
6
7
8
9
10
11
12
void MyTriPlanarSurfaceFunction (
    inout SurfaceData surface, SurfaceParameters parameters
) {
    ...
    float4 mosX = tex2D(_MOSMap, triUV.x);
    float4 mosY = tex2D(_MOSMap, triUV.y);
    float4 mosZ = tex2D(_MOSMap, triUV.z);
    float3 tangentNormalX = UnpackNormal(tex2D(_NormalMap, triUV.x));
    float3 tangentNormalY = UnpackNormal(tex2D(_NormalMap, triUV.y));
    float3 tangentNormalZ = UnpackNormal(tex2D(_NormalMap, triUV.z));
    ...
}

我们可以像混合其他数据一样混合法线,只需将其归一化即可。但是,这仅对世界空间法线有效,而我们采样的是切线空间法线。让我们首先假设可以直接将它们用作世界空间法线,看看会发生什么。为了更明显,再次将法线用于反照率。

1
2
3
4
5
6
7
8
9
10
11
12
float3 tangentNormalX = UnpackNormal(tex2D(_NormalMap, triUV.x));
float3 tangentNormalY = UnpackNormal(tex2D(_NormalMap, triUV.y));
float3 tangentNormalZ = UnpackNormal(tex2D(_NormalMap, triUV.z));
float3 worldNormalX = tangentNormalX;
float3 worldNormalY = tangentNormalY;
float3 worldNormalZ = tangentNormalZ;
float3 triW = GetTriplanarWeights(parameters);
...
surface.normal = normalize(
    worldNormalX * triW.x + worldNormalY * triW.y + worldNormalZ * triW.z
);
surface.albedo = surface.normal * 0.5 + 0.5;
切线空间中的投影法线
切线空间中的投影法线

最终的法线向量是不正确的。切线空间法线在 Z 通道中存储了它们的局部上方向——远离表面——因此结果主要是蓝色的。这与 Z 投影的 XYZ 方向匹配,但不匹配其他两个。

在 Y 投影的情况下,上方向对应于 Y,而不是 Z。所以我们必须交换 Y 和 Z 以将切线空间转换为世界空间。同样,我们必须为 X 投影交换 X 和 Z。

1
2
3
4
5
6
float3 tangentNormalX = UnpackNormal(tex2D(_NormalMap, triUV.x));
float3 tangentNormalY = UnpackNormal(tex2D(_NormalMap, triUV.y));
float3 tangentNormalZ = UnpackNormal(tex2D(_NormalMap, triUV.z));
float3 worldNormalX = tangentNormalX.zyx;
float3 worldNormalY = tangentNormalY.xzy;
float3 worldNormalZ = tangentNormalZ;
世界空间中的投影法线
世界空间中的投影法线

因为我们取负了 X 坐标以防止镜像,所以我们也必须对切线空间法线向量执行此操作。否则它们仍然会被镜像。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
float3 tangentNormalX = UnpackNormal(tex2D(_NormalMap, triUV.x));
float3 tangentNormalY = UnpackNormal(tex2D(_NormalMap, triUV.y));
float3 tangentNormalZ = UnpackNormal(tex2D(_NormalMap, triUV.z));
if (parameters.normal.x < 0) {
    tangentNormalX.x = -tangentNormalX.x;
}
if (parameters.normal.y < 0) {
    tangentNormalY.x = -tangentNormalY.x;
}
if (parameters.normal.z >= 0) {
    tangentNormalZ.x = -tangentNormalZ.x;
}
float3 worldNormalX = tangentNormalX.zyx;
float3 worldNormalY = tangentNormalY.xzy;
float3 worldNormalZ = tangentNormalZ;

在这些情况下,我们还必须翻转法线的上方向,因为它们指向内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (parameters.normal.x < 0) {
    tangentNormalX.x = -tangentNormalX.x;
    tangentNormalX.z = -tangentNormalX.z;
}
if (parameters.normal.y < 0) {
    tangentNormalY.x = -tangentNormalY.x;
    tangentNormalY.z = -tangentNormalY.z;
}
if (parameters.normal.z >= 0) {
    tangentNormalZ.x = -tangentNormalZ.x;
}
else {
    tangentNormalZ.z = -tangentNormalZ.z;
}
取消镜像并翻转的法线
取消镜像并翻转的法线

3.3 与表面法线混合

虽然法线向量现在已与它们的投影正确对齐,但它们与实际表面法线没有关系。例如,球体最终得到的法线就像立方体一样。这并不直接明显,因为我们根据实际表面法线平滑地混合这些法线,但当我们调整混合时,情况会变得更糟。

通常,我们会依赖切线到世界变换矩阵来使法线适合几何体表面。但对于我们的三个投影,我们没有这样的矩阵。我们可以做的替代方案是在每个投影法线和表面法线之间进行混合,使用 whiteout blending。我们可以使用 BlendNormals 函数,但它也会对结果进行归一化。这有点过头了,考虑到我们混合了三个结果,然后再次对该结果进行归一化。所以让我们制作我们自己的变体,不进行每次投影的归一化。

1
2
3
4
5
6
float3 BlendTriplanarNormal (float3 mappedNormal, float3 surfaceNormal) {
    float3 n;
    n.xy = mappedNormal.xy + surfaceNormal.xy;
    n.z = mappedNormal.z * surfaceNormal.z;
    return n;
}

whiteout blending 是如何工作的?

在《Rendering 6, Bumpiness》中有描述。

Whiteout blending 假设 Z 指向上方。因此将表面法线转换为投影空间,在该切线空间中执行混合,然后将结果转换回世界空间。

1
2
3
float3 worldNormalX = BlendTriplanarNormal(tangentNormalX, parameters.normal.zyx).zyx;
float3 worldNormalY = BlendTriplanarNormal(tangentNormalY, parameters.normal.xzy).xzy;
float3 worldNormalZ = BlendTriplanarNormal(tangentNormalZ, parameters.normal);
不正确的法线混合
不正确的法线混合

对于面向负方向的表面,这会出错,因为那样我们最终会乘以两个负 Z 值,从而翻转最终 Z 的符号。我们可以通过使用其中一个 Z 值的绝对值来解决此问题。但这相当于一开始就不取负采样的 Z 组件,因此我们可以直接移除那段代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (parameters.normal.x < 0) {
    tangentNormalX.x = -tangentNormalX.x;
    // tangentNormalX.z = -tangentNormalX.z;
}
if (parameters.normal.y < 0) {
    tangentNormalY.x = -tangentNormalY.x;
    // tangentNormalY.z = -tangentNormalY.z;
}
if (parameters.normal.z >= 0) {
    tangentNormalZ.x = -tangentNormalZ.x;
}
// else {
//     tangentNormalZ.z = -tangentNormalZ.z;
// }
正确的法线混合
正确的法线混合

生成的法线向量现在偏向于原始表面法线。虽然这并不完美,但通常足够了。你可以更进一步,完全放弃采样的 Z 组件,仅使用原始 Z 组件。这被称为 UDN 混合,在使用 DXT5nm 压缩时更便宜,因为不需要重建 Z 组件,但它会降低非对齐表面的法线强度。

法线贴图功能正常后,恢复原始反照率,这样我们就可以看到完整的电路材料。

1
// surface.albedo = surface.normal * 0.5 + 0.5;
使用所有电路贴图
使用所有电路贴图

3.4 缩放贴图

最后,让我们能够缩放贴图。通常,这是通过单个纹理的平铺和偏移值完成的,但这对于三平面映射没有太大意义。偏移量不太有用,非均匀缩放也是。因此,改用单个缩放属性。

1
2
3
4
5
6
7
Properties {
    [NoScaleOffset] _MainTex ("Albedo", 2D) = "white" {}
    [NoScaleOffset] _MOSMap ("MOS", 2D) = "white" {}
    [NoScaleOffset] _NormalMap ("Normals", 2D) = "white" {}
    
    _MapScale ("Map Scale", Float) = 1
}
带有贴图缩放的材质
带有贴图缩放的材质

添加所需的贴图缩放变量,并在确定 UV 坐标时使用它来缩放位置。

1
2
3
4
5
6
7
8
9
float _MapScale;
struct TriplanarUV {
    float2 x, y, z;
};
TriplanarUV GetTriplanarUV (SurfaceParameters parameters) {
    TriplanarUV triUV;
    float3 p = parameters.position * _MapScale;
    ...
}
使用双倍贴图缩放
使用双倍贴图缩放

4. 调整混合权重

最终的表面数据是通过使用原始表面法线在三个映射之间进行混合找到的。到目前为止,我们一直直接用法线,仅取其绝对值并对结果进行归一化,使权重的总和为 1。这是最直接的方法,但也可以通过各种方式微调权重。

4.1 混合偏移

更改权重计算的第一种方法是引入偏移(offset)。如果我们从所有权重中减去相同的量,那么较小的权重比大的权重受到的影响更大,这会改变它们的相对重要性。它们甚至可能变成负值。添加一个混合偏移属性来实现这一点。

我们必须确保并非所有权重都变成负值,因此最大偏移量应小于最大可能的最小权重,即当法线向量的所有三个分量相等时。那是 $\sqrt{1/3}$,约为 0.577,但我们就用 0.5 作为最大值,0.25 作为默认值。

1
2
_MapScale ("Map Scale", Float) = 1
_BlendOffset ("Blend Offset", Range(0, 0.5)) = 0.25
带有混合偏移的材质
带有混合偏移的材质

在对权重进行归一化之前减去偏移量,看看效果如何。

1
2
3
4
5
6
7
float _BlendOffset;
...
float3 GetTriplanarWeights (SurfaceParameters parameters) {
    float3 triW = abs(parameters.normal);
    triW = triW - _BlendOffset;
    return triW / (triW.x + triW.y + triW.z);
}
不正确的偏移用法
不正确的偏移用法

当混合权重保持正值时看起来不错,但负权重最终会从最终数据中减去。为了防止这种情况,在归一化之前对权重进行饱和处理(clamp)。

1
triW = saturate(triW - _BlendOffset);

结果是偏移量越高,混合区域越小。为了更清楚地看到混合如何变化,将权重用于反照率。

1
2
3
4
5
6
void MyTriPlanarSurfaceFunction (
    inout SurfaceData surface, SurfaceParameters parameters
) {
    ...
    surface.albedo = triW;
}
调整偏移量
调整偏移量

4.2 混合指数

另一种缩小混合区域的方法是通过幂运算(exponentiation),在归一化之前将权重提高到大于 1 的某个幂次。这类似于偏移,但是非线性的。为此添加一个着色器属性,使用任意最大值 8 和默认值 2。

1
2
_BlendOffset ("Blend Offset", Range(0, 0.5)) = 0.25
_BlendExponent ("Blend Exponent", Range(1, 8)) = 2
带有混合指数的材质
带有混合指数的材质

使用 pow 函数在偏移后应用指数。

1
2
3
4
5
6
7
8
float _BlendOffset, _BlendExponent;
...
float3 GetTriplanarWeights (SurfaceParameters parameters) {
    float3 triW = abs(parameters.normal);
    triW = saturate(triW - _BlendOffset);
    triW = pow(triW, _BlendExponent);
    return triW / (triW.x + triW.y + triW.z);
}
调整指数
调整指数

你最终可能会同时使用这两种方法来调整混合权重。如果你定下最终指数为 2、4 或 8,则可以硬编码几次乘法而不是依赖 pow

4.3 基于高度的混合

除了依靠原始表面法线,我们还可以让表面数据影响混合。如果表面数据包含高度,那么可以将其计入权重。我们的 MOS 贴图仍然有一个未使用的通道,因此可以将它们转换为 MOHS 贴图,包含金属度、遮蔽、高度和光滑度数据。这里是电路材料的这样一张图。它与 MOS 图相同,但在蓝色通道中包含高度数据。

电路 MOHS 贴图
电路 MOHS 贴图

将我们的 MOS 属性重命名为 MOHS 并分配新纹理。确保其 sRGB 导入复选框已禁用。

1
2
3
[NoScaleOffset] _MainTex ("Albedo", 2D) = "white" {}
[NoScaleOffset] _MOHSMap ("MOHS", 2D) = "white" {}
[NoScaleOffset] _NormalMap ("Normals", 2D) = "white" {}
现在带有 MOHS 贴图的材质
现在带有 MOHS 贴图的材质

同时也重命名变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
sampler2D _MOHSMap;
...
void MyTriPlanarSurfaceFunction (
    inout SurfaceData surface, SurfaceParameters parameters
) {
    ...
    float4 mohsX = tex2D(_MOHSMap, triUV.x);
    float4 mohsY = tex2D(_MOHSMap, triUV.y);
    float4 mohsZ = tex2D(_MOHSMap, triUV.z);
    ...
    float4 mohs = mohsX * triW.x + mohsY * triW.y + mohsZ * triW.z;
    surface.metallic = mohs.x;
    surface.occlusion = mohs.y;
    surface.smoothness = mohs.a;
    ...
}

GetTriplanarWeights 添加三个高度值的参数。让我们首先尝试直接使用高度,在幂运算之前替换法线向量。

1
2
3
4
5
6
7
8
9
float3 GetTriplanarWeights (
    SurfaceParameters parameters, float heightX, float heightY, float heightZ
) {
    float3 triW = abs(parameters.normal);
    triW = saturate(triW - _BlendOffset);
    triW = float3(heightX, heightY, heightZ);
    triW = pow(triW, _BlendExponent);
    return triW / (triW.x + triW.y + triW.z);
}

然后在调用函数时添加高度作为参数。

1
float3 triW = GetTriplanarWeights(parameters, mohsX.z, mohsY.z, mohsZ.z);
仅基于高度的混合
仅基于高度的混合

仅使用高度并不能给我们有用的结果,但可以清楚地看到金色电路条最高,因此主导了混合。现在将高度与它们各自的权重相乘。

1
triW *= float3(heightX, heightY, heightZ);
与高度相乘
与高度相乘

这看起来好多了,但高度的影响仍然非常强。调节这一点很有用,因此在着色器中添加“混合高度强度(Blend Height Strength)”属性。在最大强度下,它可能会完全消除某些权重,这不应该发生。因此将强度的范围限制在 0–0.99,默认值为 0.5。

1
2
3
_BlendOffset ("Blend Offset", Range(0, 0.5)) = 0.25
_BlendExponent ("Blend Exponent", Range(1, 8)) = 2
_BlendHeightStrength ("Blend Height Strength", Range(0, 0.99)) = 0.5
带有混合高度强度的材质
带有混合高度强度的材质

通过在 1 和高度之间进行插值来应用强度,使用强度作为插值因子。然后将权重与该结果相乘。

1
2
3
4
5
6
7
8
9
10
11
float _BlendOffset, _BlendExponent, _BlendHeightStrength;
...
float3 GetTriplanarWeights (
    SurfaceParameters parameters, float heightX, float heightY, float heightZ
) {
    float3 triW = abs(parameters.normal);
    triW = saturate(triW - _BlendOffset);
    triW *= lerp(1, float3(heightX, heightY, heightZ), _BlendHeightStrength);
    triW = pow(triW, _BlendExponent);
    return triW / (triW.x + triW.y + triW.z);
}

使用高度与偏移量结合效果最好,可以限制它们的影响范围。除此之外,更高的指数会使效果更明显。

调整高度强度
调整高度强度
调整高度强度

最后,恢复反照率以查看完整材质上的混合设置效果。

1
// surface.albedo = triW;
所有混合设置在最小 vs 最大时的对比
所有混合设置在最小 vs 最大时的对比

5. 自定义着色器 GUI

我们没有使用为其他着色器创建的着色器 GUI 类,因为它不适用于我们的三平面着色器。它依赖于我们的三平面着色器所没有的属性。虽然我们可以让 MyLightingShaderGUI 也支持这个着色器,但最好还是保持简单并创建一个新类。

5.1 基类

与其复制我们可以重用的 MyLightingShaderGUI 的基本功能,不如创建一个共同的基类供两个 GUI 扩展。让我们将其命名为 MyBaseShaderGUI。将 MyLightingShaderGUI 中的所有通用代码放入其中,省略其余部分。让所有应该直接提供给其子类的内容都设为 protected。这允许类本身及其子类访问,但其他地方不能访问。

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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
using UnityEngine;
//using UnityEngine.Rendering;
using UnityEditor;

public class MyBaseShaderGUI : ShaderGUI {
    static GUIContent staticLabel = new GUIContent();
    protected Material target;
    protected MaterialEditor editor;
    MaterialProperty[] properties;

    public override void OnGUI (
        MaterialEditor editor, MaterialProperty[] properties
    ) {
        this.target = editor.target as Material;
        this.editor = editor;
        this.properties = properties;
        // DoRenderingMode();
        // ...
        // DoAdvanced();
    }

    protected MaterialProperty FindProperty (string name) {
        return FindProperty(name, properties);
    }

    protected static GUIContent MakeLabel (string text, string tooltip = null) {
        ...
    }

    protected static GUIContent MakeLabel (
        MaterialProperty property, string tooltip = null
    ) {
        ...
    }

    protected void SetKeyword (string keyword, bool state) {
        ...
    }

    protected bool IsKeywordEnabled (string keyword) {
        return target.IsKeywordEnabled(keyword);
    }

    protected void RecordAction (string label) {
        editor.RegisterPropertyChangeUndo(label);
    }
}

MyLightingShaderGUI 继承 MyBaseShaderGUI 而不是直接继承 ShaderGUI。然后从中删除现在属于其基类一部分的所有代码。不再在 OnGUI 中自己设置变量,而是通过调用 base.OnGUI 将其委托给基类的 OnGUI 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MyLightingShaderGUI : MyBaseShaderGUI {
    ...
    public override void OnGUI (
        MaterialEditor editor, MaterialProperty[] properties
    ) {
        // this.target = editor.target as Material;
        // this.editor = editor;
        // this.properties = properties;
        base.OnGUI(editor, properties);
        DoRenderingMode();
        ...
        DoAdvanced();
    }
    ...
}

5.2 三平面着色器 GUI

添加一个新的 MyTriplanarShaderGUI 类来创建我们三平面着色器的 GUI。让它继承 MyBaseShaderGUI。给它一个 OnGUI 方法,在其中调用 base.OnGUI,然后显示贴图缩放属性。使用独立的方法来处理贴图、混合和其他设置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using UnityEngine;
using UnityEditor;

public class MyTriplanarShaderGUI : MyBaseShaderGUI {
    public override void OnGUI (
        MaterialEditor editor, MaterialProperty[] properties
    ) {
        base.OnGUI(editor, properties);
        editor.ShaderProperty(FindProperty("_MapScale"), MakeLabel("Map Scale"));
        DoMaps();
        DoBlending();
        DoOtherSettings();
    }

    void DoMaps () {}
    void DoBlending () {}
    void DoOtherSettings () {}
}

声明此类为我们三平面着色器的自定义编辑器。

1
2
3
4
Shader "Custom/Triplanar Mapping" {
    ...
    CustomEditor "MyTriplanarShaderGUI"
}
仅显示贴图缩放
仅显示贴图缩放

5.3 贴图

为贴图部分创建一个标签,然后在单行上显示三个纹理属性。为 MOHS 贴图提供提示工具,以解释每个通道应包含的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void DoMaps () {
    GUILayout.Label("Maps", EditorStyles.boldLabel);
    
    editor.TexturePropertySingleLine(
        MakeLabel("Albedo"), FindProperty("_MainTex")
    );
    editor.TexturePropertySingleLine(
        MakeLabel(
            "MOHS",
            "Metallic (R) Occlusion (G) Height (B) Smoothness (A)"
        ),
        FindProperty("_MOHSMap")
    );
    editor.TexturePropertySingleLine(
        MakeLabel("Normals"), FindProperty("_NormalMap")
    );
}
贴图 GUI
贴图 GUI

5.4 混合

混合部分很简单,只需一个标签和三个属性。

1
2
3
4
5
6
7
8
9
10
void DoBlending () {
    GUILayout.Label("Blending", EditorStyles.boldLabel);
    editor.ShaderProperty(FindProperty("_BlendOffset"), MakeLabel("Offset"));
    editor.ShaderProperty(
        FindProperty("_BlendExponent"), MakeLabel("Exponent")
    );
    editor.ShaderProperty(
        FindProperty("_BlendHeightStrength"), MakeLabel("Height Strength")
    );
}
混合 GUI
混合 GUI

5.5 其他设置

对于其他设置,通过调用 MaterialEditor.RenderQueueField 允许自定义渲染队列。同时也能够切换 GPU 实例化。

1
2
3
4
5
void DoOtherSettings () {
    GUILayout.Label("Other Settings", EditorStyles.boldLabel);
    editor.RenderQueueField();
    editor.EnableInstancingField(); 
}
其他设置 GUI
其他设置 GUI

6. 独立的顶部贴图

通常,你不希望外观完全统一。最明显的例子是地形,其中水平表面——那些指向上的,而不是向下的——可以是草地,而所有其他表面可以是岩石。你甚至可能想将三平面映射与纹理溅射(texture splatting)相结合,但那很昂贵,因为需要更多的纹理采样。替代方法是依靠贴花(decals)、其他细节对象或顶点颜色来增加多样性。

6.1 更多贴图

为了支持独立的顶部贴图,我们需要添加三个替代贴图属性。

1
2
3
4
5
6
[NoScaleOffset] _MainTex ("Albedo", 2D) = "white" {}
[NoScaleOffset] _MOHSMap ("MOHS", 2D) = "white" {}
[NoScaleOffset] _NormalMap ("Normals", 2D) = "white" {}
[NoScaleOffset] _TopMainTex ("Top Albedo", 2D) = "white" {}
[NoScaleOffset] _TopMOHSMap ("Top MOHS", 2D) = "white" {}
[NoScaleOffset] _TopNormalMap ("Top Normals", 2D) = "white" {}

并非总是需要独立的顶部贴图,因此我们将其设为一个着色器特性,使用 _SEPARATE_TOP_MAPS 关键字。除了阴影通道外,为所有通道添加对它的支持。

1
2
#pragma target 3.0
#pragma shader_feature _SEPARATE_TOP_MAPS

将这些额外的贴图添加到我们的着色器 GUI 中。使用顶部反照率贴图来确定是否应设置该关键字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void DoMaps () {
    GUILayout.Label("Top Maps", EditorStyles.boldLabel);
    MaterialProperty topAlbedo = FindProperty("_TopMainTex");
    Texture topTexture = topAlbedo.textureValue;
    EditorGUI.BeginChangeCheck();
    editor.TexturePropertySingleLine(MakeLabel("Albedo"), topAlbedo);
    if (EditorGUI.EndChangeCheck() && topTexture != topAlbedo.textureValue) {
        SetKeyword("_SEPARATE_TOP_MAPS", topAlbedo.textureValue);
    }
    editor.TexturePropertySingleLine(
        MakeLabel(
            "MOHS",
            "Metallic (R) Occlusion (G) Height (B) Smoothness (A)"
        ),
        FindProperty("_TopMOHSMap")
    );
    editor.TexturePropertySingleLine(
        MakeLabel("Normals"), FindProperty("_TopNormalMap")
    );
    GUILayout.Label("Maps", EditorStyles.boldLabel);
    ...
}

6.2 使用大理石

要查看实际运行中的独立顶部贴图,我们需要另一组纹理。我们可以使用大理石反照率和法线贴图。这里是配套的 MOHS 贴图。

大理石 MOHS 贴图
大理石 MOHS 贴图

顶部使用电路——因为它是绿色的,所以有点像草——其余部分使用大理石。

顶部为电路,其余为大理石
顶部为电路,其余为大理石

由于着色器还不了解顶部贴图,我们目前只能看到大理石。

仅显示大理石
仅显示大理石

6.3 启用顶部贴图

MyTriplanarMapping 添加所需的采样器变量。在所有纹理采样后,检查关键字是否在 MyTriPlanarSurfaceFunction 中定义。如果是,添加代码以用顶部贴图的采样覆盖 Y 投影的数据。但仅对向上的表面执行此操作,即表面法线具有正 Y 分量时。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sampler2D _TopMainTex, _TopMOHSMap, _TopNormalMap;
...
void MyTriPlanarSurfaceFunction (
    inout SurfaceData surface, SurfaceParameters parameters
) {
    ...
    float3 tangentNormalZ = UnpackNormal(tex2D(_NormalMap, triUV.z));
    #if defined(_SEPARATE_TOP_MAPS)
        if (parameters.normal.y > 0) {
            albedoY = tex2D(_TopMainTex, triUV.y).rgb;
            mohsY = tex2D(_TopMOHSMap, triUV.y);
            tangentNormalY = UnpackNormal(tex2D(_TopNormalMap, triUV.y));
        }
    #endif
}

如果所有表面都指向上的话?

在典型的基于高度图的地形网格的情况下,所有表面法线都保证指向向上。因此,不需要检查法线的 Y 分量是否为正,可以省略。

这导致着色器会根据 Y 投影对常规贴图或顶部贴图进行采样。在我们的案例中,我们在大理石顶部得到了一个电路层。通常它是草、沙或雪。

顶部电路
顶部电路

默认混合设置在投影之间产生相当平滑的混合,在电路和大理石交汇的地方看起来不太好。指数设为 8 会导致更突然的过渡,这更适合这些材料。也可以为顶部贴图支持不同的混合设置,但高度混合已经允许通过 MOHS 贴图进行大量控制。

指数设为 8
指数设为 8

6.4 稍后解包

虽然着色器编译器使用 if-else 方法聪明地采样顶部或常规贴图,但它在解包法线方面不够聪明。它不能假设 UnpackNormal 的两次使用可以合并。为了帮助编译器,我们可以推迟解包原始法线,直到选择贴图之后。

1
2
3
4
5
6
7
8
9
10
11
12
13
float3 tangentNormalX = UnpackNormal(tex2D(_NormalMap, triUV.x));
// float3 tangentNormalY = UnpackNormal(tex2D(_NormalMap, triUV.y));
float4 rawNormalY = tex2D(_NormalMap, triUV.y);
float3 tangentNormalZ = UnpackNormal(tex2D(_NormalMap, triUV.z));
#if defined(_SEPARATE_TOP_MAPS)
    if (parameters.normal.y > 0) {
        albedoY = tex2D(_TopMainTex, triUV.y).rgb;
        mohsY = tex2D(_TopMOHSMap, triUV.y);
        // tangentNormalY = UnpackNormal(tex2D(_TopNormalMap, triUV.y));
        rawNormalY = tex2D(_TopNormalMap, triUV.y);
    }
#endif
float3 tangentNormalY = UnpackNormal(rawNormalY);

7. 光照贴图 (Lightmapping)

我们的三平面着色器还没有完成,因为它还不支持光照贴图。它可以接收烘培光照,但它并不对其做出贡献。通过将所有对象设为静态并将方向光切换到烘培模式最容易看到这一点。等待烘培完成,然后通过将场景视图模式从“着色(Shaded)”切换到“烘培全局光照 / 反照率(Baked Global Illumination / Albedo)”来检查烘培的反照率。所有使用三平面映射的对象都变成了黑色。

光照贴图使用黑色反照率
光照贴图使用黑色反照率

要支持光照贴图,我们必须向着色器添加一个 meta 通道,它必须依赖 My Lightmapping 而不是 My Lighting

1
2
3
4
5
6
7
8
9
10
11
Pass {
    Tags { "LightMode" = "Meta" }
    Cull Off
    CGPROGRAM
    #pragma vertex MyLightmappingVertexProgram
    #pragma fragment MyLightmappingFragmentProgram
    #pragma shader_feature _SEPARATE_TOP_MAPS
    #include "MyTriplanarMapping.cginc"
    #include "My Lightmapping.cginc"
    ENDCG
}

7.1 使用表面数据

为了让 My Lightmapping 配合我们的三平面方法工作,它也必须支持新的表面方法。为了方便,让它包含 My Lighting Input 并删除所有现在重复的变量、插值器和 getter 函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//#include "UnityPBSLighting.cginc"
#include "My Lighting Input.cginc"
#include "UnityMetaPass.cginc"
//float4 _Color;
//...
//
//float3 GetEmission (Interpolators i) {
// ...
//}
Interpolators MyLightmappingVertexProgram (VertexData v) {
    ...
}
float4 MyLightmappingFragmentProgram (Interpolators i) : SV_TARGET {
    ...
}

My Lighting 一样,它必须定义默认的反照率函数。并且它应该在 MyLightmappingFragmentProgram 中使用相同的表面方法,除了它只关心反照率、发射、金属度和光滑度。

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
#if !defined(ALBEDO_FUNCTION)
    #define ALBEDO_FUNCTION GetAlbedo
#endif

float4 MyLightmappingFragmentProgram (Interpolators i) : SV_TARGET {
    SurfaceData surface;
    surface.normal = normalize(i.normal);
    surface.albedo = 1;
    surface.alpha = 1;
    surface.emission = 0;
    surface.metallic = 0;
    surface.occlusion = 1;
    surface.smoothness = 0.5;
    #if defined(SURFACE_FUNCTION)
        SurfaceParameters sp;
        sp.normal = i.normal;
        sp.position = i.worldPos.xyz;
        sp.uv = UV_FUNCTION(i);
        SURFACE_FUNCTION(surface, sp);
    #else
        surface.albedo = ALBEDO_FUNCTION(i);
        surface.emission = GetEmission(i);
        surface.metallic = GetMetallic(i);
        surface.smoothness = GetSmoothness(i);
    #endif
    ...
}

用新的表面数据替换 getter 函数的旧用法。

1
2
3
4
5
6
7
8
9
10
11
12
13
float4 MyLightmappingFragmentProgram (Interpolators i) : SV_TARGET {
    ...
    UnityMetaInput surfaceData;
    surfaceData.Emission = surface.emission;
    float oneMinusReflectivity;
    surfaceData.Albedo = DiffuseAndSpecularFromMetallic(
        surface.albedo, surface.metallic,
        surfaceData.SpecularColor, oneMinusReflectivity
    );
    float roughness = SmoothnessToRoughness(surface.smoothness) * 0.5;
    surfaceData.Albedo += surfaceData.SpecularColor * roughness;
    return UnityMetaFragment(surfaceData);
}

7.2 包含相关输入

插值器现在还包括法线和世界位置向量,因此应该在 MyLightMappingVertexProgram 中设置它们。

1
2
3
4
5
6
7
8
9
10
11
Interpolators MyLightmappingVertexProgram (VertexData v) {
    Interpolators i;
    i.pos = UnityMetaVertexPosition(
        v.vertex, v.uv1, v.uv2, unity_LightmapST, unity_DynamicLightmapST
    );
    i.normal = UnityObjectToWorldNormal(v.normal);
    i.worldPos.xyz = mul(unity_ObjectToWorld, v.vertex);
    i.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
    i.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
    return i;
}

这些向量通常不需要,所以我们可以在不需要时跳过计算它们,只需改用虚拟常量。我们可以定义两个宏 META_PASS_NEEDS_NORMALSMETA_PASS_NEEDS_POSITION 来指示是否需要它们。

1
2
3
4
5
6
7
8
9
10
11
#if defined(META_PASS_NEEDS_NORMALS)
    i.normal = UnityObjectToWorldNormal(v.normal);
#else
    i.normal = float3(0, 1, 0);
#endif

#if defined(META_PASS_NEEDS_POSITION)
    i.worldPos.xyz = mul(unity_ObjectToWorld, v.vertex);
#else
    i.worldPos.xyz = 0;
#endif

此外,仅在需要时包含 UV 坐标。

1
2
3
4
#if !defined(NO_DEFAULT_UV)
    i.uv.xy = TRANSFORM_TEX(v.uv, _MainTex);
    i.uv.zw = TRANSFORM_TEX(v.uv, _DetailTex);
#endif

7.3 三平面光照贴图

剩下要做的就是声明我们的三平面着色器在其 meta 通道中同时需要法线和位置数据。一旦完成并且光照再次烘培,反照率将正确显示在场景视图中。

1
2
3
4
5
#pragma shader_feature _SEPARATE_TOP_MAPS
#define META_PASS_NEEDS_NORMALS
#define META_PASS_NEEDS_POSITION
#include "MyTriplanarMapping.cginc"
#include "My Lightmapping.cginc"
正确光照贴图的反照率
正确光照贴图的反照率

现在我们的三平面着色器已完全正常工作。你可以将其作为你自己工作的基础,根据需要进行扩展、微调和调整。

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