Post

自定义管线:方向光 (翻译三)

自定义管线:方向光 (翻译三)
  • 增加对多个方向光着色(shading)的支持。
  • 已升级至 2022.3.62f2

1 光照 (Lighting)

如果我们想创建一个更真实的场景,那么我们就必须模拟光如何与表面相互作用。这需要一个比我们目前拥有的不发光(unlit)着色器更复杂的着色器。

1.1 受光着色器 (Lit Shader)

复制 UnlitPass.hlsl 文件并将其重命名为 LitPass.hlsl。调整重命名(include guard define)以及顶点和片元函数名称以匹配。我们稍后会添加光照计算。

1
2
3
4
5
6
7
8
9
10
#ifndef CUSTOM_LIT_PASS_INCLUDED
#define CUSTOM_LIT_PASS_INCLUDED

...

Varyings LitPassVertex (Attributes input) { ... }

float4 LitPassFragment (Varyings input) : SV_TARGET { ... }

#endif

同时复制 Unlit 着色器并将其重命名为 Lit。更改其菜单名称、它包含的文件以及它使用的函数。让我们也将默认颜色更改为灰色,因为在光照充足的场景中,全白表面可能会显得非常亮。通用管线(URP)默认也使用灰色。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Shader "Custom RP/Lit" {
    Properties {
        _BaseMap("Texture", 2D) = "white" {}
        _BaseColor("Color", Color) = (0.5, 0.5, 0.5, 1.0)
        ...
    }
    SubShader {
        Pass {
            ...
            #pragma vertex LitPassVertex
            #pragma fragment LitPassFragment
            #include "LitPass.hlsl"
            ENDHLSL
        }
    }
}

我们将使用自定义光照方法,我们将通过将着色器的 LightMode 设置为 CustomLit 来指示这一点。在 Pass 中添加一个 Tags 块,包含 "LightMode" = "CustomLit"

1
2
3
4
5
6
        Pass {
            Tags {
                "LightMode" = "CustomLit"
            }
            ...
        }

为了渲染使用此 Pass 的对象,我们必须在 CameraRenderer 中包含它。首先为其添加一个着色器标签标识符。

1
2
3
    static ShaderTagId
        unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit"),
        litShaderTagId = new ShaderTagId("CustomLit");

然后将其添加到 DrawVisibleGeometry 中要渲染的 Pass 中,就像我们在 DrawUnsupportedShaders 中所做的一样。

1
2
3
4
5
6
7
        var drawingSettings = new DrawingSettings(
            unlitShaderTagId, sortingSettings
        ) {
            enableDynamicBatching = useDynamicBatching,
            enableInstancing = useGPUInstancing
        };
        drawingSettings.SetShaderPassName(1, litShaderTagId);

现在我们可以创建一个新的不透明材质,尽管此时它的结果与不发光材质相同。

默认不透明材质
默认不透明材质

1.2 法线向量 (Normal Vectors)

物体的受光程度取决于多种因素,包括光线与表面之间的相对角度。为了了解表面的朝向,我们需要访问表面法线(surface normal),这是一个垂直于表面的单位长度向量。该向量是顶点数据的一部分,在对象空间中定义,就像位置一样。因此,在 LitPassAttributes 中添加它。

1
2
3
4
5
6
struct Attributes {
    float3 positionOS : POSITION;
    float3 normalOS : NORMAL;
    float2 baseUV : TEXCOORD0;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

由于需要逐像素级计算的,所以我们也必须在 Varyings 中添加法线向量。我们将在世界空间中执行计算,因此将其命名为 normalWS

1
2
3
4
5
6
struct Varyings {
    float4 positionCS : SV_POSITION;
    float3 normalWS : VAR_NORMAL;
    float2 baseUV : VAR_BASE_UV;
    UNITY_VERTEX_INPUT_INSTANCE_ID
};

我们可以使用 SpaceTransforms 中的 TransformObjectToWorldNormalLitPassVertex 中将法线转换为世界空间。

1
2
3
    output.positionWS = TransformObjectToWorld(input.positionOS);
    output.positionCS = TransformWorldToHClip(positionWS);
    output.normalWS = TransformObjectToWorldNormal(input.normalOS);

为了验证我们是否在 LitPassFragment 中获得了正确的法线向量,我们可以将其用作颜色。

1
2
    base.rgb = input.normalWS;
    return base;
世界空间法线向量
世界空间法线向量

负值无法可视化,因此它们被约束到0。

1.3 插值法线 (Interpolated Normals)

虽然法线向量在顶点程序中是单位长度的,但跨三角形的线性插值会影响它们的长度。我们可以通过渲染 1 与向量长度之间的差异(放大十倍使其更明显)来可视化误差。

1
    base.rgb = abs(length(input.normalWS) - 1.0) * 10.0;
插值法线误差,夸张处理
插值法线误差,夸张处理

我们可以通过在 LitPassFragment 中归一化法线向量来平滑插值畸变。在只看法线向量时,差异并不明显,但在用于光照时会更加明显。

1
    base.rgb = normalize(input.normalWS);
插值后的归一化
插值后的归一化

1.4 表面属性 (Surface Properties)

着色器中的光照是关于模拟光线照射到表面的相互作用,这意味着我们必须跟踪表面的属性。现在我们有一个法线向量和一个基础颜色。我们可以将后者分为两部分:RGB 颜色和 alpha 值。我们将在几个地方使用这些数据,所以让我们定义一个方便的 Surface 结构体来包含所有相关数据。将其放在 ShaderLibrary 文件夹中一个单独的 Surface.hlsl 文件中。

1
2
3
4
5
6
7
8
9
10
#ifndef CUSTOM_SURFACE_INCLUDED
#define CUSTOM_SURFACE_INCLUDED

struct Surface {
    float3 normal;
    float3 color;
    float alpha;
};

#endif

将它包含在 LitPass 中,位于 Common 之后。这样我们可以保持 LitPass 简洁。从现在开始,我们将把专业代码放在它自己的 HLSL 文件中,以便更容易找到相关功能。

1
2
#include "../ShaderLibrary/Common.hlsl"
#include "../ShaderLibrary/Surface.hlsl"

LitPassFragment 中定义一个 surface 变量并填充它。然后最终结果变成表面的颜色和 alpha。

1
2
3
4
5
6
    Surface surface;
    surface.normal = normalize(input.normalWS);
    surface.color = base.rgb;
    surface.alpha = base.a;

    return float4(surface.color, surface.alpha);

1.5 计算光照 (Calculating Lighting)

为了计算实际光照,我们将创建一个具有 Surface 参数的 GetLighting 函数。最初让它返回表面法线的 Y 分量。由于这是光照功能,我们将把它放在 ShaderLibrary 文件夹中一个单独的 Lighting.hlsl 文件中。

1
2
3
4
5
6
7
8
#ifndef CUSTOM_LIGHTING_INCLUDED
#define CUSTOM_LIGHTING_INCLUDED

float3 GetLighting (Surface surface) {
    return surface.normal.y;
}

#endif

将它包含在 LitPass 中,在包含 Surface 之后,因为 Lighting 依赖于它。

1
2
#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Lighting.hlsl"

现在我们可以在 LitPassFragment 中获取光照,并将其用于片元的 RGB 部分。

1
2
    float3 color = GetLighting(surface);
    return float4(color, surface.alpha);
来自上方的漫反射光照
来自上方的漫反射光照

此时,结果是表面法线的 Y 分量,因此它在球体顶部为 1,在侧面下降到 0。再往下,结果变为负值,在底部达到 -1,但我们看不见负值。它匹配法线和向上向量之间夹角的余弦值。忽略负数部分,这在视觉上匹配指向正下方的方向光的漫反射光照(diffuse lighting)。最后的修饰是在 GetLighting 中将表面颜色计入结果,将其解释为表面反照率(albedo)。

1
2
3
float3 GetLighting (Surface surface) {
    return surface.normal.y * surface.color;
}
应用反照率
应用反照率

反照率(albedo)是什么意思? Albedo 在拉丁语中意为“白度”。它是衡量表面散射反射多少光的一个指标。如果反照率不是全白,那么部分光能就会被吸收而不是反射。

2 灯光 (Lights)

为了执行正确的光照,我们还需要知道灯光的属性。在本教程中,我们将仅限制在方向光上。方向光代表一个距离非常远的光源,以至于其位置无关紧要,只有其方向重要。这是一个简化,但足以模拟地球上的太阳光以及入射光或多或少是单向的其他情况。

2.1 灯光结构 (Light Structure)

我们将使用一个结构体来存储灯光数据。目前,颜色和方向就足够了。将其放在一个单独的 Light.hlsl 文件中。同时定义一个 GetDirectionalLight 函数,返回一个配置好的方向光。最初使用白色和向上向量,匹配我们当前使用的灯光数据。请注意,灯光的方向被定义为光线来自的方向,而不是它去的方向。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#ifndef CUSTOM_LIGHT_INCLUDED
#define CUSTOM_LIGHT_INCLUDED

struct Light {
    float3 color;
    float3 direction;
};

Light GetDirectionalLight () {
    Light light;
    light.color = 1.0;
    light.direction = float3(0.0, 1.0, 0.0);
    return light;
}

#endif

Lighting 之前将文件包含在 LitPass 中。

1
2
#include "../ShaderLibrary/Light.hlsl"
#include "../ShaderLibrary/Lighting.hlsl"

2.2 光照函数 (Lighting Functions)

Lighting 中添加一个 IncomingLight 函数,用于计算给定表面和光源的入射光量。对于任意光照方向,我们必须计算表面法线和方向的点积。我们可以使用 dot 函数。结果应该由灯光的颜色调制。

1
2
3
float3 IncomingLight (Surface surface, Light light) {
    return dot(surface.normal, light.direction) * light.color;
}

但这只有在表面朝向灯光时才正确。当点积为负时,我们必须将其约束为零,我们可以通过 saturate 函数来实现。

1
2
3
float3 IncomingLight (Surface surface, Light light) {
    return saturate(dot(surface.normal, light.direction)) * light.color;
}

saturate 的作用是什么? 它将值约束在 0 和 1 之间(含 0 和 1)。我们只需要指定最小值,因为点积永远不应该大于 1,但饱和(saturation)是着色器的一种常见操作,通常是免费的操作修饰符。

添加另一个 GetLighting 函数,它返回表面和灯光的最终光照。目前,它是入射光乘以表面颜色。在另一个函数上方定义它。

1
2
3
float3 GetLighting (Surface surface, Light light) {
    return IncomingLight(surface, light) * surface.color;
}

最后,调整只有一个 Surface 参数的 GetLighting 函数,使其调用另一个函数,使用 GetDirectionalLight 提供灯光数据。

1
2
3
float3 GetLighting (Surface surface) {
    return GetLighting(surface, GetDirectionalLight());
}

2.3 发送灯光数据到 GPU (Sending Light Data to the GPU)

我们不应该总是使用来自上方的白光,而应该使用当前场景的灯光。默认场景自带一个代表太阳的方向光,颜色略带黄色——十六进制 FFF4D6——并且绕 X 轴旋转 50°,绕 Y 轴旋转 -30°。如果这种灯光不存在,请创建一个。

为了使灯光的数据在着色器中可访问,我们必须为其创建统一值(uniform values),就像着色器属性一样。在这种情况下,我们将定义两个 float3 向量:_DirectionalLightColor_DirectionalLightDirection。将它们放在 Light 顶部定义的 _CustomLight 缓冲区中。

1
2
3
4
CBUFFER_START(_CustomLight)
    float3 _DirectionalLightColor;
    float3 _DirectionalLightDirection;
CBUFFER_END

GetDirectionalLight 中使用这些值而不是常量。

1
2
3
4
5
6
Light GetDirectionalLight () {
    Light light;
    light.color = _DirectionalLightColor;
    light.direction = _DirectionalLightDirection;
    return light;
}

现在我们的 RP 必须将灯光数据发送到 GPU。我们将为此创建一个新的 Lighting 类。它的工作方式类似于 CameraRenderer,但用于灯光。给它一个带有 context 参数的公共 Setup 方法,在其中调用一个单独的 SetupDirectionalLight 方法。虽然不是严格必要,但我们也给它一个专门的命令缓冲区,我们在完成后执行它,这对于调试很方便。另一种选择是添加一个缓冲区参数。

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

public class Lighting {
    const string bufferName = "Lighting";
    CommandBuffer buffer = new CommandBuffer {
        name = bufferName
    };

    public void Setup (ScriptableRenderContext context) {
        buffer.BeginSample(bufferName);
        SetupDirectionalLight();
        buffer.EndSample(bufferName);
        context.ExecuteCommandBuffer(buffer);
        buffer.Clear();
    }

    void SetupDirectionalLight () {}
}

跟踪这两个着色器属性的标识符。

1
2
3
    static int
        dirLightColorId = Shader.PropertyToID("_DirectionalLightColor"),
        dirLightDirectionId = Shader.PropertyToID("_DirectionalLightDirection");

我们可以通过 RenderSettings.sun 访问场景的主光源。默认情况下,这会得到最重要的方向光,也可以在 Window / Rendering / Lighting Settings 中显式配置。使用 CommandBuffer.SetGlobalVector 将灯光数据发送到 GPU。颜色是灯光在线性空间中的颜色,而方向是灯光变换的前向向量取反。

1
2
3
4
5
    void SetupDirectionalLight () {
        Light light = RenderSettings.sun;
        buffer.SetGlobalVector(dirLightColorId, light.color.linear);
        buffer.SetGlobalVector(dirLightDirectionId, -light.transform.forward);
    }

SetGlobalVector 不需要 Vector4 吗? 是的,发送到 GPU 的向量始终有四个分量,即使我们定义的分量较少。额外的分量在着色器中被隐式掩码。同样,存在从 Vector3Vector4 的隐式转换,尽管反向不行。

灯光的颜色属性是其配置的颜色,但灯光也有一个单独的强度因子。最终颜色是两者相乘的结果。

1
2
3
buffer.SetGlobalVector(
    dirLightColorId, light.color.linear * light.intensity
);

CameraRenderer 提供一个 Lighting 实例,并在绘制可见几何体之前使用它来设置光照。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    Lighting lighting = new Lighting();

    public void Render (
        ScriptableRenderContext context, Camera camera,
        bool useDynamicBatching, bool useGPUInstancing
    ) {
        ...
        Setup();
        lighting.Setup(context);
        DrawVisibleGeometry(useDynamicBatching, useGPUInstancing);
        DrawUnsupportedShaders();
        DrawGizmos();
        Submit();
    }
被照亮
被照亮

2.4 可见光 (Visible Lights)

在剔除时,Unity 还会确定哪些灯光影响摄像机可见的空间。我们可以依靠这些信息而不是全局太阳。为此,Lighting 需要访问剔除结果,因此为 Setup 添加一个参数并在字段中存储它以便于使用。然后我们可以支持多个灯光,因此将 SetupDirectionalLight 的调用替换为新的 SetupLights 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
    CullingResults cullingResults;

    public void Setup (
        ScriptableRenderContext context, CullingResults cullingResults
    ) {
        this.cullingResults = cullingResults;
        buffer.BeginSample(bufferName);
        //SetupDirectionalLight();
        SetupLights();
        ...
    }

    void SetupLights () {}

CameraRenderer.Render 中调用 Setup 时添加剔除结果作为参数。

1
        lighting.Setup(context, cullingResults);

现在 Lighting.SetupLights 可以通过剔除结果的 visibleLights 属性检索所需数据。它是以 Unity.Collections.NativeArray 形式提供的,元素类型为 VisibleLight

1
2
3
4
5
6
7
8
9
10
11
using Unity.Collections;
using UnityEngine;
using UnityEngine.Rendering;

public class Lighting {
    ...
    void SetupLights () {
        NativeArray<VisibleLight> visibleLights = cullingResults.visibleLights;
    }
    ...
}

什么是 NativeArray? 它是一个行为类似于数组的结构体,但提供了与本机内存缓冲区的连接。它使得在托管 C# 代码和本机 Unity 引擎代码之间高效共享数据成为可能。

2.5 多个方向光 (Multiple Directional Lights)

使用可见光数据使得支持多个方向光成为可能,但我们必须将所有这些灯光的数据发送到 GPU。因此,我们将使用两个 Vector4 数组加上一个用于灯光数量的整数,而不是一对向量。我们还将定义方向光的最大数量,我们可以使用它来初始化两个数组字段以缓冲数据。让我们将最大值设置为 4,这对于大多数场景应该足够了。

1
2
3
4
5
6
7
8
9
10
11
12
    const int maxDirLightCount = 4;

    static int
        //dirLightColorId = Shader.PropertyToID("_DirectionalLightColor"),
        //dirLightDirectionId = Shader.PropertyToID("_DirectionalLightDirection");
        dirLightCountId = Shader.PropertyToID("_DirectionalLightCount"),
        dirLightColorsId = Shader.PropertyToID("_DirectionalLightColors"),
        dirLightDirectionsId = Shader.PropertyToID("_DirectionalLightDirections");

    static Vector4[]
        dirLightColors = new Vector4[maxDirLightCount],
        dirLightDirections = new Vector4[maxDirLightCount];

SetupDirectionalLight 添加一个索引和一个 VisibleLight 参数。让它使用提供的索引设置颜色和方向元素。在这种情况下,最终颜色通过 visibleLight.finalColor 属性提供。前向向量可以通过 visibleLight.localToWorldMatrix 属性找到。它是矩阵的第三列,并且再次需要取反。

1
2
3
4
    void SetupDirectionalLight (int index, VisibleLight visibleLight) {
        dirLightColors[index] = visibleLight.finalColor;
        dirLightDirections[index] = -visibleLight.localToWorldMatrix.GetColumn(2);
    }

最终颜色已经应用了灯光的强度,但默认情况下 Unity 不会将其转换为线性空间。我们必须将 GraphicsSettings.lightsUseLinearIntensity 设置为 true,我们可以在 CustomRenderPipeline 的构造函数中执行此操作。

1
2
3
4
5
6
7
8
    public CustomRenderPipeline (
        bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher
    ) {
        this.useDynamicBatching = useDynamicBatching;
        this.useGPUInstancing = useGPUInstancing;
        GraphicsSettings.useScriptableRenderPipelineBatching = useSRPBatcher;
        GraphicsSettings.lightsUseLinearIntensity = true;
    }

接下来,循环遍历 Lighting.SetupLights 中的所有可见光并为每个元素调用 SetupDirectionalLight。然后在缓冲区上调用 SetGlobalIntSetGlobalVectorArray 以将数据发送到 GPU。

1
2
3
4
5
6
7
8
9
        NativeArray<VisibleLight> visibleLights = cullingResults.visibleLights;
        for (int i = 0; i < visibleLights.Length; i++) {
            VisibleLight visibleLight = visibleLights[i];
            SetupDirectionalLight(i, visibleLight);
        }

        buffer.SetGlobalInt(dirLightCountId, visibleLights.Length);
        buffer.SetGlobalVectorArray(dirLightColorsId, dirLightColors);
        buffer.SetGlobalVectorArray(dirLightDirectionsId, dirLightDirections);

但我们只支持最多四个方向光,所以当我们达到该最大值时应该中止循环。让我们保持方向光索引与循环迭代器分开。

1
2
3
4
5
6
7
8
9
10
        int dirLightCount = 0;
        for (int i = 0; i < visibleLights.Length; i++) {
            VisibleLight visibleLight = visibleLights[i];
            SetupDirectionalLight(dirLightCount++, visibleLight);
            if (dirLightCount >= maxDirLightCount) {
                break;
            }
        }

        buffer.SetGlobalInt(dirLightCountId, dirLightCount);

因为我们只支持方向光,所以我们应该忽略其他类型的灯光。我们可以通过检查可见光的 lightType 属性是否等于 LightType.Directional 来实现这一点。

1
2
3
4
5
6
7
VisibleLight visibleLight = visibleLights[i];
if (visibleLight.lightType == LightType.Directional) {
    SetupDirectionalLight(dirLightCount++, visibleLight);
    if (dirLightCount >= maxDirLightCount) {
        break;
    }
}

这可行,但 VisibleLight 结构体相当大。理想情况下,我们只从原生数组中检索它一次,而且不要将其作为常规参数传递给 SetupDirectionalLight,因为这会复制它。我们可以使用 Unity 对 ScriptableRenderContext.DrawRenderers 方法使用的相同技巧,即通过引用传递参数。

1
SetupDirectionalLight(dirLightCount++, ref visibleLight);

这要求我们也将会参数定义为引用。

1
void SetupDirectionalLight (int index, ref VisibleLight visibleLight) { ... }

2.6 着色器循环 (Shader Loop)

调整 Light 中的 _CustomLight 缓冲区以使其匹配我们的新数据格式。在这种情况下,我们将明确地为数组类型使用 float4。数组在着色器中具有固定大小,无法调整大小。确保使用我们在 Lighting 中定义的相同最大值。

1
2
3
4
5
6
7
#define MAX_DIRECTIONAL_LIGHT_COUNT 4

CBUFFER_START(_CustomLight)
    int _DirectionalLightCount;
    float4 _DirectionalLightColors[MAX_DIRECTIONAL_LIGHT_COUNT];
    float4 _DirectionalLightDirections[MAX_DIRECTIONAL_LIGHT_COUNT];
CBUFFER_END

添加一个获取方向光数量的函数,并调整 GetDirectionalLight 以使其检索特定灯光索引的数据。

1
2
3
4
5
6
7
8
9
10
int GetDirectionalLightCount () {
    return _DirectionalLightCount;
}

Light GetDirectionalLight (int index) {
    Light light;
    light.color = _DirectionalLightColors[index].rgb;
    light.direction = _DirectionalLightDirections[index].xyz;
    return light;
}

rgb 和 xyz 之间有区别吗? 它们是语义别名。使用 rgbaxyzw 进行混写(Swizzling)是等效的。

然后调整表面的 GetLighting,使其使用 for 循环来累加所有方向光的贡献。

1
2
3
4
5
6
7
float3 GetLighting (Surface surface) {
    float3 color = 0.0;
    for (int i = 0; i < GetDirectionalLightCount(); i++) {
        color += GetLighting(surface, GetDirectionalLight(i));
    }
    return color;
}
四个方向光
四个方向光

现在我们的着色器支持最多四个方向光。通常只需要一个方向光来代表太阳或月亮,但也许在拥有多个太阳的行星上有一个场景。方向光也可以用来近似多个大型灯光装置,例如大型体育场的灯光。

如果你的游戏始终只有一个方向光,那么你可以去掉循环,或者制作多个着色器变体。但在本教程中,我们将保持简单,坚持使用一个通用的循环。最好的性能始终是通过剥离所有你不需要的东西来实现的,尽管它并不总是产生显著差异。

2.7 着色器目标级别 (Shader Target Level)

使用变量长度的循环过去对着色器来说是个问题,但现代 GPU 可以毫无问题地处理它们,特别是当绘制调用的所有片元都以相同的方式迭代相同的数据时。但是,OpenGL ES 2.0 和 WebGL 1.0 图形 API 默认无法处理此类循环。我们可以通过合并硬编码的最大值来使其工作,例如让 GetDirectionalLight 返回 min(_DirectionalLightCount, MAX_DIRECTIONAL_LIGHT_COUNT)。这使得展开(unroll)循环成为可能,将其转换为一系列条件代码块。不幸的是,生成的着色器代码一团糟,性能会迅速下降。在非常陈旧的硬件上,所有代码块将始终执行,它们的贡献通过条件分配来控制。虽然我们可以使其工作,但它会使代码变得更复杂,因为我们也必须做出其他调整。所以我选择忽略这些限制,为了简单起见在构建中关闭 WebGL 1.0 和 OpenGL ES 2.0 支持。它们无论如何都不支持线性光照。我们还可以通过 #pragma target 3.5 指令将着色器 Pass 的目标级别提高到 3.5 来避免为它们编译 OpenGL ES 2.0 着色器变体。让我们保持一致,对两个着色器都这样做。

1
2
3
4
HLSLPROGRAM
#pragma target 3.5
...
ENDHLSL

3 BRDF

我们目前使用一个非常简单的光照模型,仅适用于完全漫反射表面。我们可以通过应用双向反射分布函数(BRDF)来实现更多样化、更真实的光照。此类函数有很多。我们将使用通用管线(URP)所使用的那一个,它权衡了一些真实感以换取性能。

3.1 入射光 (Incoming Light)

当光束正面照射到表面片元时,其所有能量都将影响该片元。为简单起见,我们将假设光束的宽度与片元的宽度匹配。这就是光线方向 $L$ 和表面法线 $N$ 对齐的情况,因此 $N \cdot L = 1$。当它们不对齐时,光束的至少一部分会错过表面片元,因此影响片元的能量较少。影响片元的能量部分是 $N \cdot L$。负值结果意味着表面背离光源,因此不受其影响。

入射光部分
入射光部分

3.2 出射光 (Outgoing Light)

我们不会直接看到到达表面的光。我们只看到从表面反弹并到达摄像机或我们眼睛的那部分。如果表面是一个完全平坦的镜子,那么光线会反射出去,出射角等于入射角。只有当摄像机与该光线对齐时,我们才能看到此光线。这被称为高光反射(specular reflection)。这是光交互的简化,但对于我们的目的来说已经足够了。

完全高光反射
完全高光反射

但如果表面不是完全平坦的,那么光线就会被散射,因为该片元实际上由许多具有不同方向的更小片元组成。这会将光束分解成走向不同方向的小光束,从而有效地模糊了高光反射。即使没有与完美反射方向对齐,我们最终也可能会看到一些散射光。

散射高光反射
散射高光反射

除此之外,光线还会穿透表面,到处反弹,并以不同的角度射出,以及其他我们不需要考虑的事情。取极端情况,我们最终会得到一个完美的漫反射表面,它向所有可能的方向均匀散射光。这就是我们目前在着色器中计算的光照。

完美漫反射反射
完美漫反射反射

无论摄像机位于何处,从表面接收到的漫反射光量都是相同的。但这意味着我们观察到的光能远小于到达表面 fragment 的光能。这表明我们应该用某个系数来缩放进入的光线。然而,由于该系数始终保持不变,我们可以将其直接合并到光的颜色和强度中。因此,我们使用的最终光照代表了在正面照射下,从一个完美的漫反射表面反射入射光时观察到的光量。这只是实际发射的总光量的一小部分。还有其他配置灯光的方法,例如通过指定流明(lumen)或(lux),这使得配置现实的光源更加容易,但我们先使用目前入门的无其他干扰的算法。

3.3 表面属性 (Surface Properties)

表面可以是完全漫反射、完美镜面反射或介于两者之间的任何状态。我们可以通过多种方式控制这一点。我们将使用金属度工作流(metallic workflow),这要求我们在 Lit 着色器中添加两个表面属性。

  1. 第一个属性是表面是金属还是非金属,也称为电介质(dielectric)。由于表面可以包含两者的混合,我们将为其添加一个 $0-1$ 范围的滑块,1 表示完全金属。默认值为完全电介质。
  2. 第二个属性控制表面的平滑程度。我们也将为此使用 $0-1$ 范围的滑块,0 表示完全粗糙,1 表示完全平滑。我们将使用 0.5 作为默认值。
1
2
_Metallic ("Metallic", Range(0, 1)) = 0
_Smoothness ("Smoothness", Range(0, 1)) = 0.5
带有金属度和光滑度滑块的材质
带有金属度和光滑度滑块的材质

将这些属性添加到 UnityPerMaterial 缓冲区中。

1
2
3
4
5
6
7
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
    UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)
    UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
    UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
    UNITY_DEFINE_INSTANCED_PROP(float, _Metallic)
    UNITY_DEFINE_INSTANCED_PROP(float, _Smoothness)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

同时也添加到 Surface 结构体中。

1
2
3
4
5
6
7
struct Surface {
    float3 normal;
    float3 color;
    float alpha;
    float metallic;
    float smoothness;
};

LitPassFragment 中将它们复制到表面。

1
2
3
4
5
6
7
    Surface surface;
    surface.normal = normalize(input.normalWS);
    surface.color = base.rgb;
    surface.alpha = base.a;
    surface.metallic = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Metallic);
    surface.smoothness =
        UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Smoothness);

并为 PerObjectMaterialProperties 添加对它们的支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    static int
        baseColorId = Shader.PropertyToID("_BaseColor"),
        cutoffId = Shader.PropertyToID("_Cutoff"),
        metallicId = Shader.PropertyToID("_Metallic"),
        smoothnessId = Shader.PropertyToID("_Smoothness");
    ...
    [SerializeField, Range(0f, 1f)]
    float alphaCutoff = 0.5f, metallic = 0f, smoothness = 0.5f;
    ...
    void OnValidate () {
        ...
        block.SetFloat(metallicId, metallic);
        block.SetFloat(smoothnessId, smoothness);
        GetComponent<Renderer>().SetPropertyBlock(block);
    }

3.4 BRDF 属性 (BRDF Properties)

我们将使用表面属性来计算 BRDF 方程。它告诉我们最终看到的从表面反射了多少光,这是漫反射和高光反射的组合。我们需要将表面颜色分为漫反射部分和高光部分,我们还需要知道表面的粗糙程度。让我们在 ShaderLibrary 文件夹中一个单独的 BRDF.hlsl 文件中跟踪这三个值。

1
2
3
4
5
6
7
8
9
10
#ifndef CUSTOM_BRDF_INCLUDED
#define CUSTOM_BRDF_INCLUDED

struct BRDF {
    float3 diffuse;
    float3 specular;
    float roughness;
};

#endif

添加一个获取给定表面的 BRDF 数据的函数。从完美的漫反射表面开始,因此漫反射部分等于表面颜色,而高光部分为黑色,粗糙度为 1。

1
2
3
4
5
6
7
BRDF GetBRDF (Surface surface) {
    BRDF brdf;
    brdf.diffuse = surface.color;
    brdf.specular = 0.0;
    brdf.roughness = 1.0;
    return brdf;
}

Light 之后、Lighting 之前包含 BRDF

1
2
3
4
5
#include "../ShaderLibrary/Common.hlsl"
#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Light.hlsl"
#include "../ShaderLibrary/BRDF.hlsl"
#include "../ShaderLibrary/Lighting.hlsl"

为两个 GetLighting 函数添加一个 BRDF 参数,然后将入射光乘以漫反射部分而不是整个表面颜色。

1
2
3
4
5
6
7
8
9
10
11
float3 GetLighting (Surface surface, BRDF brdf, Light light) {
    return IncomingLight(surface, light) * brdf.diffuse;
}

float3 GetLighting (Surface surface, BRDF brdf) {
    float3 color = 0.0;
    for (int i = 0; i < GetDirectionalLightCount(); i++) {
        color += GetLighting(surface, brdf, GetDirectionalLight(i));
    }
    return color;
}

最后,在 LitPassFragment 中获取 BRDF 数据并将其传递给 GetLighting

1
2
    BRDF brdf = GetBRDF(surface);
    float3 color = GetLighting(surface, brdf);

3.5 反射率 (Reflectivity)

表面的反射程度各不相同,但一般金属通过高光反射反射所有光线,漫反射反射为零。所以我们将声明反射率等于金属度表面属性。被反射的光不会被漫反射,所以我们应该在 GetBRDF 中通过 1 减去反射率来缩放漫反射颜色。

1
2
    float oneMinusReflectivity = 1.0 - surface.metallic;
    brdf.diffuse = surface.color * oneMinusReflectivity;
金属度为 0、0.25、0.5、0.75 和 1 的白色球体
金属度为 0、0.25、0.5、0.75 和 1 的白色球体

实际上,一些光也会从电介质表面反弹,这给它们带来了亮点。非金属的反射率各不相同,但平均约为 0.04。让我们将其定义为最小反射率,并添加一个 OneMinusReflectivity 函数,将范围从 0-1 调整为 0-0.96。此范围调整与通用管线(URP)的方法匹配。

1
2
3
4
5
6
#define MIN_REFLECTIVITY 0.04

float OneMinusReflectivity (float metallic) {
    float range = 1.0 - MIN_REFLECTIVITY;
    return range - metallic * range;
}

GetBRDF 中使用该函数来强制执行最小值。在仅渲染漫反射反射时差异几乎察觉不到,但在我们添加高光反射时会很重要。没有它,非金属将不会获得高光。

1
    float oneMinusReflectivity = OneMinusReflectivity(surface.metallic);

3.6 高光颜色 (Specular Color)

被反射走的光线不能再以另一种方式被反射。这被称为能量守恒,这意味着出射光量不能超过入射光量。这表明高光颜色应该等于表面颜色减去漫反射颜色。

1
2
    brdf.diffuse = surface.color * oneMinusReflectivity;
    brdf.specular = surface.color - brdf.diffuse;

但是,这忽略了金属影响高光反射颜色而非金属不影响这一事实。电介质表面的高光颜色应该是白色的,我们可以通过使用金属度属性在最小反射率和表面颜色之间进行插值来实现。

1
    brdf.specular = lerp(MIN_REFLECTIVITY, surface.color, surface.metallic);

3.7 粗糙度 (Roughness)

粗糙度是平滑度的相反面,所以我们可以简单地用 1 减去平滑度。Core RP 库有一个执行此操作的函数,名为 PerceptualSmoothnessToPerceptualRoughness。我们将使用此函数,以明确平滑度以及粗糙度都被定义为感知的。我们可以通过 PerceptualRoughnessToRoughness 函数转换为实际的粗糙度值,该函数对感知值进行平方。这匹配 Disney 光照模型。之所以这样做,是因为在编辑材质时调整感知版本更直观。

1
2
3
    float perceptualRoughness =
        PerceptualSmoothnessToPerceptualRoughness(surface.smoothness);
    brdf.roughness = PerceptualRoughnessToRoughness(perceptualRoughness);

这些函数在 Core RP 库的 CommonMaterial.hlsl 文件中定义。在包含核心的 Common 之后将其包含在我们的 Common 文件中。

1
2
3
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
#include "UnityInput.hlsl"

3.8 视角方向 (View Direction)

为了确定摄像机与完美反射方向的对齐程度,我们需要知道摄像机的位置。Unity 通过 float3 _WorldSpaceCameraPos 提供此数据,所以将其添加到 UnityInput 中。

1
float3 _WorldSpaceCameraPos;

为了获得视角方向——从表面到摄像机的方向——在 LitPassFragment 中,我们需要在 Varyings 中添加世界空间表面位置。

1
2
3
4
5
6
7
8
9
10
11
12
struct Varyings {
    float4 positionCS : SV_POSITION;
    float3 positionWS : VAR_POSITION;
    ...
};

Varyings LitPassVertex (Attributes input) {
    ...
    output.positionWS = TransformObjectToWorld(input.positionOS);
    output.positionCS = TransformWorldToHClip(output.positionWS);
    ...
}

我们将视角方向视为表面数据的一部分,因此将其添加到 Surface 中。

1
2
3
4
5
6
7
8
struct Surface {
    float3 normal;
    float3 viewDirection;
    float3 color;
    float alpha;
    float metallic;
    float smoothness;
};

LitPassFragment 中分配它。它等于摄像机位置减去片元位置,再归一化。

1
2
    surface.normal = normalize(input.normalWS);
    surface.viewDirection = normalize(_WorldSpaceCameraPos - input.positionWS);

3.9 高光强度 (Specular Strength)

我们观察到的高光反射强度取决于我们的视角方向与完美反射方向的匹配程度。我们将使用与通用管线(URP)相同的公式,它是 Minimalist CookTorrance BRDF 的变体。该公式包含一些平方,所以让我们首先为 Common 添加一个方便的 Square 函数。

1
2
3
float Square (float v) {
    return v * v;
}

然后为 BRDF 添加一个以表面、BRDF 数据和灯光为参数的 SpecularStrength 函数。它应该计算 $\frac{r^2}{d^2 \max (0.1, (L \cdot H)^2) n}$,其中 $r$ 是粗糙度,所有点积都应该饱和。此外,$d = (N \cdot H)^2(r^2 - 1) + 1.0001$,$N$ 是表面法线,$L$ 是灯光方向,$H = L + V$ 归一化,它是灯光和视角方向之间的半程向量(halfway vector)。使用 SafeNormalize 函数来归一化该向量,以避免在向量相反的情况下除以零。最后,$n = 4r + 2$ 且是一个归一化项。

1
2
3
4
5
6
7
8
9
float SpecularStrength (Surface surface, BRDF brdf, Light light) {
    float3 h = SafeNormalize(light.direction + surface.viewDirection);
    float nh2 = Square(saturate(dot(surface.normal, h)));
    float lh2 = Square(saturate(dot(light.direction, h)));
    float r2 = Square(brdf.roughness);
    float d2 = Square(nh2 * (r2 - 1.0) + 1.0001);
    float normalization = brdf.roughness * 4.0 + 2.0;
    return r2 / (d2 * max(0.1, lh2) * normalization);
}

那个函数是如何工作的? BRDF 理论太复杂,无法简短地完全解释,而且也不是本教程的重点。你可以查看 URP 的 Lighting.hlsl 文件以获取一些代码文档和参考。

接下来,添加一个 DirectBRDF,它返回通过直接光照获得颜色,给定表面、BRDF 和灯光。结果是高光颜色由高光强度调制,加上漫反射颜色。

1
2
3
float3 DirectBRDF (Surface surface, BRDF brdf, Light light) {
    return SpecularStrength(surface, brdf, light) * brdf.specular + brdf.diffuse;
}

GetLighting 然后必须将入射光乘以该函数的结果。

1
2
3
float3 GetLighting (Surface surface, BRDF brdf, Light light) {
    return IncomingLight(surface, light) * DirectBRDF(surface, brdf, light);
}
平滑度从上到下分别为 0、0.25、0.5、0.75 和 0.95
平滑度从上到下分别为 0、0.25、0.5、0.75 和 0.95

我们现在获得了高光反射,这为我们的表面增加了亮点。对于完全粗糙的表面,亮点模拟漫反射反射。平滑的表面获得更集中的亮点。完全平滑的表面获得极其微小的亮点,我们看不见。需要一些散射才能使其可见。

由于能量守恒,平滑表面的亮点可能会变得非常亮,因为到达表面片元的大部分光都变得集中了。因此,我们最终看到的光比漫反射反射可能看到的光要多得多。你可以通过将最终渲染颜色大幅缩小来验证这一点。

最终颜色除以 100
最终颜色除以 100

你也可以通过使用白色以外的基础颜色来验证金属会影响高光反射的颜色而非金属不影响。

蓝色基础颜色
蓝色基础颜色

我们现在有了功能性的、可信的直接光照,尽管目前结果太暗了——特别是对于金属——因为我们还不支持环境反射。此时均匀的黑色环境会比默认的天空盒更真实,但这会使我们的对象更难看到。添加更多灯光也有效。

四个灯光
四个灯光

3.10 网格球 (Mesh Ball)

让我们也为 MeshBall 添加对变化的金属度和光滑度属性的支持。这需要添加两个 float 数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
    static int
        baseColorId = Shader.PropertyToID("_BaseColor"),
        metallicId = Shader.PropertyToID("_Metallic"),
        smoothnessId = Shader.PropertyToID("_Smoothness");
    ...
    float[]
        metallic = new float[1023],
        smoothness = new float[1023];
    ...
    void Update () {
        if (block == null) {
            block = new MaterialPropertyBlock();
            block.SetVectorArray(baseColorId, baseColors);
            block.SetFloatArray(metallicId, metallic);
            block.SetFloatArray(smoothnessId, smoothness);
        }
        Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 1023, block);
    }

让我们在 Awake 中让 25% 的实例具有金属感,并让平滑度从 0.05 变化到 0.95。

1
2
3
4
5
6
7
    baseColors[i] =
        new Vector4(
            Random.value, Random.value, Random.value,
            Random.Range(0.5f, 1f)
        );
    metallic[i] = Random.value < 0.25f ? 1f : 0f;
    smoothness[i] = Random.Range(0.05f, 0.95f);

然后让网格球使用受光材质。

受光网格球
受光网格球

4 透明度 (Transparency)

让我们再次考虑透明度。对象仍然基于其 alpha 值淡出,但现在是反射光淡出。这对于漫反射反射是有意义的,因为只有一部分光被反射,而其余光穿过表面。

淡出球体
淡出球体

但是,高光反射也会淡出。在完全透明的玻璃的情况下,光线要么穿过要么被反射。高光反射不会淡出。我们目前的做法无法表示这一点。

4.1 预乘 Alpha (Premultiplied Alpha)

解决方案是只淡出漫反射光,同时保持高光反射为全强度。由于源混合模式应用于所有内容,我们无法使用它,所以让我们将其设置为 1,同时仍然为目标混合模式使用 one-minus-source-alpha。

源混合模式设置为 one
源混合模式设置为 one
源混合模式设置为 one

这恢复了高光反射,但漫反射反射不再淡出。我们通过将表面 alpha 因子计入漫反射颜色来解决这个问题。因此,我们对漫反射进行预乘 alpha 处理,而不是稍后依赖 GPU 混合。这种方法被称为预乘 alpha 混合(premultiplied alpha blending)。在 GetBRDF 中执行此操作。

1
2
    brdf.diffuse = surface.color * oneMinusReflectivity;
    brdf.diffuse *= surface.alpha;
预乘漫反射
预乘漫反射

4.2 预乘切换 (Premultiplication Toggle)

将 alpha 与漫反射预乘有效地将对象变成了玻璃,而常规 alpha 混合使对象有效地仅部分存在。让我们通过为 GetBRDF 添加一个布尔参数来支持两者,以控制我们是否预乘 alpha,默认设置为 false

1
2
3
4
5
6
7
BRDF GetBRDF (inout Surface surface, bool applyAlphaToDiffuse = false) {
    ...
    if (applyAlphaToDiffuse) {
        brdf.diffuse *= surface.alpha;
    }
    ...
}

我们可以在 LitPassFragment 中使用 _PREMULTIPLY_ALPHA 关键字来决定使用哪种方法,类似于我们控制 alpha 裁剪的方式。

1
2
3
4
5
6
7
#if defined(_PREMULTIPLY_ALPHA)
    BRDF brdf = GetBRDF(surface, true);
#else
    BRDF brdf = GetBRDF(surface);
#endif
    float3 color = GetLighting(surface, brdf);
    return float4(color, surface.alpha);

Lit 的 Pass 添加关键字的着色器特征(shader feature)。

1
2
#pragma shader_feature _CLIPPING
#pragma shader_feature _PREMULTIPLY_ALPHA

并为着色器也添加一个切换属性。

1
[Toggle(_PREMULTIPLY_ALPHA)] _PremulAlpha ("Premultiply Alpha", Float) = 0
预乘 alpha 切换
预乘 alpha 切换

5 着色器 GUI (Shader GUI)

我们现在支持多种渲染模式,每种模式都需要特定设置。为了更轻松地在模式之间切换,让我们为材质检查器添加一些按钮以应用预设配置。

5.1 自定义着色器 GUI (Custom Shader GUI)

Lit 着色器的主块底部添加一个 CustomEditor "CustomShaderGUI" 语句。

1
2
3
4
Shader "Custom RP/Lit" {
    ...
    CustomEditor "CustomShaderGUI"
}

这指示 Unity 编辑器使用 CustomShaderGUI 类的实例来绘制使用 Lit 着色器的材质的检查器。创建一个该类的脚本资产并将其放在一个新的 Custom RP / Editor 文件夹中。

我们需要使用 UnityEditorUnityEngineUnityEngine.Rendering 命名空间。该类必须继承 ShaderGUI 并重写公共 OnGUI 方法,该方法具有 MaterialEditorMaterialProperty 数组参数。让它调用基类方法,这样我们最终得到默认的检查器。

1
2
3
4
5
6
7
8
9
10
11
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;

public class CustomShaderGUI : ShaderGUI {
    public override void OnGUI (
        MaterialEditor materialEditor, MaterialProperty[] properties
    ) {
        base.OnGUI(materialEditor, properties);
    }
}

5.2 设置属性和关键字 (Setting Properties and Keywords)

为了完成我们的工作,我们需要访问三样东西,我们将它们存储在字段中。首先是材质编辑器,它是负责显示和编辑材质的基础编辑器对象。其次是对正在编辑的材质的引用,我们可以通过编辑器的 targets 属性检索它。它被定义为 Object 数组,因为 targets 是通用 Editor 类的属性。第三是可以编辑的属性数组。

1
2
3
4
5
6
7
8
9
10
11
12
    MaterialEditor editor;
    Object[] materials;
    MaterialProperty[] properties;

    public override void OnGUI (
        MaterialEditor materialEditor, MaterialProperty[] properties
    ) {
        base.OnGUI(materialEditor, properties);
        editor = materialEditor;
        materials = materialEditor.targets;
        this.properties = properties;
    }

为什么有多个材质? 可以同时编辑使用相同着色器的多个材质,就像你可以选择并编辑多个游戏对象一样。

要设置属性,我们首先必须在数组中找到它,为此我们可以使用 ShaderGUI.FindProperty 方法,传递名称和属性数组。然后我们可以通过给其 floatValue 属性赋值来调整其值。将其封装在一个方便的、带有名称和值参数的 SetProperty 方法中。

1
2
3
    void SetProperty (string name, float value) {
        FindProperty(name, properties).floatValue = value;
    }

设置关键字稍微复杂一些。我们将为此创建一个 SetKeyword 方法,它带有一个名称和一个布尔参数,指示是启用还是禁用该关键字。我们必须在所有材质上调用 EnableKeywordDisableKeyword,并向它们传递关键字名称。

1
2
3
4
5
6
7
8
9
10
11
12
    void SetKeyword (string keyword, bool enabled) {
        if (enabled) {
            foreach (Material m in materials) {
                m.EnableKeyword(keyword);
            }
        }
        else {
            foreach (Material m in materials) {
                m.DisableKeyword(keyword);
            }
        }
    }

让我们还创建一个 SetProperty 变体,用于切换属性-关键字组合。

1
2
3
4
    void SetProperty (string name, string keyword, bool value) {
        SetProperty(name, value ? 1f : 0f);
        SetKeyword(keyword, value);
    }

现在我们可以定义简单的 ClippingPremultiplyAlphaSrcBlendDstBlendZWrite 设置器属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    bool Clipping {
        set => SetProperty("_Clipping", "_CLIPPING", value);
    }

    bool PremultiplyAlpha {
        set => SetProperty("_PremulAlpha", "_PREMULTIPLY_ALPHA", value);
    }

    BlendMode SrcBlend {
        set => SetProperty("_SrcBlend", (float)value);
    }

    BlendMode DstBlend {
        set => SetProperty("_DstBlend", (float)value);
    }

    bool ZWrite {
        set => SetProperty("_ZWrite", value ? 1f : 0f);
    }

最后,渲染队列通过分配给所有材质的 renderQueue 属性来设置。我们可以为此使用 RenderQueue 枚举。

1
2
3
4
5
6
7
    RenderQueue RenderQueue {
        set {
            foreach (Material m in materials) {
                m.renderQueue = (int)value;
            }
        }
    }

5.3 预设按钮 (Preset Buttons)

可以通过 GUILayout.Button 方法创建一个按钮,并向其传递一个标签,该标签将是预设的名称。如果该方法返回 true,则表示它被按下了。在应用预设之前,我们应该向编辑器注册一个撤消步骤,这可以通过调用 RegisterPropertyChangeUndo 并向其传递名称来完成。由于此代码对于所有预设都是相同的,因此将其放在一个 PresetButton 方法中,该方法返回是否应应用预设。

1
2
3
4
5
6
7
    bool PresetButton (string name) {
        if (GUILayout.Button(name)) {
            editor.RegisterPropertyChangeUndo(name);
            return true;
        }
        return false;
    }

我们将为每个预设创建一个单独的方法,从默认的 Opaque 模式开始。让它在激活时适当地设置属性。

1
2
3
4
5
6
7
8
9
10
    void OpaquePreset () {
        if (PresetButton("Opaque")) {
            Clipping = false;
            PremultiplyAlpha = false;
            SrcBlend = BlendMode.One;
            DstBlend = BlendMode.Zero;
            ZWrite = true;
            RenderQueue = RenderQueue.Geometry;
        }
    }

第二个预设是 Clip,它是 Opaque 的副本,打开了裁剪并将队列设置为 AlphaTest

1
2
3
4
5
6
7
8
9
10
    void ClipPreset () {
        if (PresetButton("Clip")) {
            Clipping = true;
            PremultiplyAlpha = false;
            SrcBlend = BlendMode.One;
            DstBlend = BlendMode.Zero;
            ZWrite = true;
            RenderQueue = RenderQueue.AlphaTest;
        }
    }

第三个预设是标准透明度,它会淡出对象,所以我们将它命名为 Fade。它是 Opaque 的另一个副本,调整了混合模式和队列,另外没有深度写入。

1
2
3
4
5
6
7
8
9
10
    void FadePreset () {
        if (PresetButton("Fade")) {
            Clipping = false;
            PremultiplyAlpha = false;
            SrcBlend = BlendMode.SrcAlpha;
            DstBlend = BlendMode.OneMinusSrcAlpha;
            ZWrite = false;
            RenderQueue = RenderQueue.Transparent;
        }
    }

第四个预设是 Fade 的变体,它应用预乘 alpha 混合。我们将它命名为 Transparent,因为它适用于具有正确光照的半透明表面。

1
2
3
4
5
6
7
8
9
10
    void TransparentPreset () {
        if (PresetButton("Transparent")) {
            Clipping = false;
            PremultiplyAlpha = true;
            SrcBlend = BlendMode.One;
            DstBlend = BlendMode.OneMinusSrcAlpha;
            ZWrite = false;
            RenderQueue = RenderQueue.Transparent;
        }
    }

OnGUI 末尾调用预设方法,这样它们就会显示在默认检查器下方。

1
2
3
4
5
6
7
8
9
    public override void OnGUI (
        MaterialEditor materialEditor, MaterialProperty[] properties
    ) {
        ...
        OpaquePreset();
        ClipPreset();
        FadePreset();
        TransparentPreset();
    }
预设按钮
预设按钮

5.4 预设折叠 (Preset Foldout)

预设按钮不会经常使用,所以让我们把它们放在一个默认折叠的折叠栏(foldout)里。这通过调用 EditorGUILayout.Foldout 来完成,传入当前的折叠状态、标签和 true 以指示点击它应该切换其状态。它返回新的折叠状态,我们应该将其存储在一个字段中。仅当折叠栏打开时才绘制按钮。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    bool showPresets;
    ...
    public override void OnGUI (
        MaterialEditor materialEditor, MaterialProperty[] properties
    ) {
        ...
        EditorGUILayout.Space();
        showPresets = EditorGUILayout.Foldout(showPresets, "Presets", true);
        if (showPresets) {
            OpaquePreset();
            ClipPreset();
            FadePreset();
            TransparentPreset();
        }
    }
预设折叠
预设折叠

5.5 不发光预设 (Presets for Unlit)

我们也可以为我们的 Unlit 着色器使用自定义着色器 GUI。

1
2
3
4
Shader "Custom RP/Unlit" {
    ...
    CustomEditor "CustomShaderGUI"
}

但是,激活预设将导致错误,因为我们正试图设置着色器没有的属性。我们可以通过调整 SetProperty 来防范这种情况。让它通过 false 作为额外参数调用 FindProperty,指示如果找不到属性,则不应记录错误。结果将为 null,所以只有在这种情况下才设置值。同时返回属性是否存在。

1
2
3
4
5
6
7
8
    bool SetProperty (string name, float value) {
        MaterialProperty property = FindProperty(name, properties, false);
        if (property != null) {
            property.floatValue = value;
            return true;
        }
        return false;
    }

然后调整 SetProperty 的关键字版本,使其仅在相关属性存在时才设置关键字。

1
2
3
4
5
    void SetProperty (string name, string keyword, bool value) {
        if (SetProperty(name, value ? 1f : 0f)) {
            SetKeyword(keyword, value);
        }
    }

5.6 无透明度 (No Transparency)

现在预设也适用于使用 Unlit 着色器的材质,尽管在这种情况下 Transparent 模式没有太大意义,因为相关属性不存在。让我们在不相关时隐藏此预设。

首先,添加一个返回属性是否存在的 HasProperty 方法。

1
2
    bool HasProperty (string name) =>
        FindProperty(name, properties, false) != null;

其次,创建一个方便的属性来检查 _PremultiplyAlpha 是否存在。

1
    bool HasPremultiplyAlpha => HasProperty("_PremulAlpha");

最后,通过在 TransparentPreset 中首先检查该属性,使 Transparent 预设的所有内容都以该属性为条件。

1
    if (HasPremultiplyAlpha && PresetButton("Transparent")) { ... }
不发光材质缺少透明预设
不发光材质缺少透明预设

下一篇方向阴影 (Directional Shadows)

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