Post

自定义渲染管线:多摄像机渲染 (翻译十四)

深入探讨Unity自定义渲染管线中的多摄像机渲染技术,包括分屏渲染、摄像机混合以及渲染层遮罩的实现。

自定义渲染管线:多摄像机渲染 (翻译十四)
  • 使用不同的后处理设置渲染多个摄像机
  • 使用自定义混合模式对摄像机进行分层
  • 支持渲染层遮罩(Rendering Layer Masks)
  • 为每个摄像机遮罩光源

组合摄像机

由于剔除(culling)、光照处理和阴影渲染都是针对每个摄像机执行的,因此每帧渲染尽可能少的摄像机是一个好主意,理想情况下只渲染一个。但有时我们确实需要同时渲染多个不同的视角。例如分屏多人游戏、后视镜、俯视图叠加层、游戏内摄像机以及3D角色肖像等场景。

分屏渲染

让我们首先考虑一个分屏场景,由两个并排的摄像机组成。左侧摄像机的视口矩形宽度设置为 0.5。右侧摄像机的宽度也为 0.5,其 X 位置设置为 0.5。如果我们不使用后处理效果,这会按预期工作。

不使用后处理的分屏,显示同一场景的两个不同视图
不使用后处理的分屏,显示同一场景的两个不同视图

但如果我们启用后处理效果就会失败。两个摄像机都以正确的大小渲染,但最终覆盖了整个摄像机目标缓冲区,只有最后一个可见。

使用后处理的分屏,错误显示
使用后处理的分屏,错误显示

这是因为调用 SetRenderTarget 也会将视口重置为覆盖整个目标。要将视口应用于最终后处理通道,我们必须在设置目标之后、绘制之前设置视口。让我们通过复制 PostFXStack.Draw 来实现这一点,将其重命名为 DrawFinal,并在 SetRenderTarget 之后直接在缓冲区上调用 SetViewport,以摄像机的 pixelRect 作为参数。由于这是最终绘制,我们可以将除源参数之外的所有参数替换为硬编码值。

1
2
3
4
5
6
7
8
9
10
11
12
void DrawFinal (RenderTargetIdentifier from) {
    buffer.SetGlobalTexture(fxSourceId, from);
    buffer.SetRenderTarget(
        BuiltinRenderTextureType.CameraTarget,
        RenderBufferLoadAction.DontCare, RenderBufferStoreAction.Store
    );
    buffer.SetViewport(camera.pixelRect);
    buffer.DrawProcedural(
        Matrix4x4.identity, settings.Material,
        (int)Pass.Final, MeshTopology.Triangles, 3
    );
}

DoColorGradingAndToneMapping 结尾处调用新方法而不是常规的 Draw

1
2
3
4
5
6
void DoColorGradingAndToneMapping (int sourceId) {
    ....
    //Draw(....)
    DrawFinal(sourceId);
    buffer.ReleaseTemporaryRT(colorGradingLUTId);
}
使用后处理的分屏,正确显示
使用后处理的分屏,正确显示

如果你使用基于瓦片(tile-based)的 GPU,可能会在渲染视口边缘周围出现渲染伪影,超出其边界。这是因为被遮罩掉的瓦片区域部分包含垃圾数据。我们通过在不使用完整视口时加载目标来修复这个问题。这不是 Unity 2022 特有的,但我注意到这个问题是因为 Apple Silicon Mac 具有基于瓦片的 GPU 并支持 don’t-care 选项,但它们在我编写本系列时还不存在。

1
2
3
4
5
6
7
8
9
10
11
12
static Rect fullViewRect = new Rect(0f, 0f, 1f, 1f);
....
void DrawFinal (RenderTargetIdentifier from) {
    buffer.SetGlobalTexture(fxSourceId, from);
    buffer.SetRenderTarget(
        BuiltinRenderTextureType.CameraTarget,
        camera.rect == fullViewRect ?
            RenderBufferLoadAction.DontCare : RenderBufferLoadAction.Load,
        RenderBufferStoreAction.Store
    );
    ....
}

摄像机分层

除了渲染到单独的区域外,我们还可以让摄像机视口重叠。最简单的例子是使用一个覆盖整个屏幕的常规主摄像机,然后添加一个稍后渲染的第二个摄像机,具有相同的视图但视口较小。我将第二个视口缩小到一半大小,并通过将其 XY 位置设置为 0.25 来使其居中。

两个摄像机层
两个摄像机层

如果我们不使用后处理效果,那么可以通过将顶部摄像机层设置为仅清除深度来将其转换为部分透明的叠加层。这会移除它的天空盒,显示下面的层。但当使用后处理效果时这不起作用,因为我们会强制将其设置为 CameraClearFlags.Color,所以我们会看到摄像机的背景颜色,默认为深蓝色。

第二个摄像机设置为仅清除深度,不使用和使用后处理效果
第二个摄像机设置为仅清除深度,不使用和使用后处理效果
第二个摄像机设置为仅清除深度,不使用和使用后处理效果

让层透明度与后处理效果一起工作的一种方法是更改 PostFXStack 着色器的最终通道,使其执行 Alpha 混合而不是默认的 One Zero 模式。

1
2
3
4
5
6
7
8
9
Pass {
    Name "Final"
    Blend SrcAlpha OneMinusSrcAlpha
    HLSLPROGRAM
    #pragma target 3.5
    #pragma vertex DefaultPassVertex
    #pragma fragment FinalPassFragment
    ENDHLSL
}

这确实需要我们在 DrawFinal 中始终加载目标缓冲区。

1
2
3
4
5
6
7
8
void DrawFinal (RenderTargetIdentifier from) {
    buffer.SetGlobalTexture(fxSourceId, from);
    buffer.SetRenderTarget(
        BuiltinRenderTextureType.CameraTarget,
        RenderBufferLoadAction.Load, RenderBufferStoreAction.Store
    );
    ....
}

现在将叠加摄像机的背景颜色的 alpha 设置为零。只要我们禁用泛光(bloom),这似乎可以工作。我添加了两个非常明亮的自发光对象,以明显显示泛光是否处于活动状态。

泛光禁用和启用
泛光禁用和启用
泛光禁用和启用

它不能与泛光一起工作,因为该效果目前不保留透明度。我们可以通过调整最终泛光通道来修复这个问题,使其保持高分辨率源纹理的原始透明度。我们必须调整 BloomAddPassFragmentBloomScatterFinalPassFragment,因为它们中的任何一个都可能用于最终绘制。

1
2
3
4
5
6
7
8
9
10
11
12
float4 BloomAddPassFragment (Varyings input) : SV_TARGET {
    ....
    float4 highRes = GetSource2(input.screenUV);
    return float4(lowRes * _BloomIntensity + highRes.rgb, highRes.a);
}
....
float4 BloomScatterFinalPassFragment (Varyings input) : SV_TARGET {
    ....
    float4 highRes = GetSource2(input.screenUV);
    lowRes += highRes.rgb - ApplyBloomThreshold(highRes.rgb);
    return float4(lerp(highRes.rgb, lowRes, _BloomIntensity), highRes.a);
}
带透明度和泛光的分层
带透明度和泛光的分层

透明度现在可以与泛光一起工作,但泛光对透明区域的贡献不再可见。我们可以通过将最终通道切换到预乘 alpha 混合来保留泛光。这确实需要我们将摄像机的背景颜色设置为纯透明黑色,因为它将添加到下面的层。

1
2
Name "Final"
Blend One OneMinusSrcAlpha
泛光影响透明区域
泛光影响透明区域

分层 Alpha

我们当前的分层方法仅在我们的着色器产生合理的 alpha 值(与摄像机层混合一起工作)时才有效。我们之前不关心写入的 alpha 值,因为我们从未将它们用于任何用途。但现在,如果两个 alpha 为 0.5 的对象最终渲染到同一个纹素,该纹素的最终 alpha 应该是 0.25。当任一 alpha 值为 1 时,结果应该始终为 1。当第二个 alpha 为零时,应保留原始 alpha。所有这些情况都通过在混合 alpha 时使用 One OneMinusSrcAlpha 来覆盖。我们可以通过在颜色混合模式之后添加逗号,然后是 alpha 的模式,来为 alpha 通道单独配置着色器的混合模式。对我们的 Lit 和 Unlit 着色器的常规通道都这样做。

1
Blend [_SrcBlend] [_DstBlend], One OneMinusSrcAlpha

只要使用适当的 alpha 值,这就会起作用,这通常意味着写入深度的对象也应该始终产生 alpha 值为 1。这对于不透明材质来说似乎很简单,但如果它们最终使用也包含变化 alpha 的基础贴图,就会出错。对于裁剪材质也可能出错,因为它们依赖 alpha 阈值来丢弃片段。如果片段被裁剪,那没问题,但如果没有,其 alpha 应该变为 1。

alpha 为零的不透明立方体添加到基础层而不是替换它
alpha 为零的不透明立方体添加到基础层而不是替换它

确保我们的着色器的 alpha 行为正确的最快方法是将 _ZWrite 添加到 UnityPerMaterial 缓冲区,在 LitInputUnlitInput 中都添加。

1
2
UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
UNITY_DEFINE_INSTANCED_PROP(float, _ZWrite)

然后在两个输入文件中添加一个带有 alpha 参数的 GetFinalAlpha 函数。如果 _ZWrite 设置为 1,它返回 1,否则返回提供的值。

1
2
3
float GetFinalAlpha (float alpha) {
    return INPUT_PROP(_ZWrite) ? 1.0 : alpha;
}

LitPassFragment 中通过此函数过滤表面 alpha 以在最后获得正确的 alpha 值。

1
2
3
4
float4 LitPassFragment (Varyings input) : SV_TARGET {
    ....
    return float4(color, GetFinalAlpha(surface.alpha));
}

UnlitPassFragment 中的基础 alpha 也做同样的处理。

1
2
3
4
float4 UnlitPassFragment (Varyings input) : SV_TARGET {
    ....
    return float4(base.rgb, GetFinalAlpha(base.a));
}

自定义混合

与前一个摄像机层混合仅对叠加摄像机有意义。底部摄像机将与摄像机目标的初始内容混合,这些内容要么是随机的,要么是前一帧的累积,除非编辑器提供清除的目标。因此第一个摄像机应该使用 One Zero 模式进行混合。为了支持替换、叠加和更奇特的分层选项,我们将为摄像机添加一个可配置的最终混合模式,在启用后处理效果时使用。我们将创建一个新的可序列化的 CameraSettings 配置类用于这些设置,就像我们为阴影所做的那样。为了方便,将源和目标混合模式包装在一个内部 FinalBlendMode 结构中,然后默认将其设置为 One Zero 混合。

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

[Serializable]
public class CameraSettings {

    [Serializable]
    public struct FinalBlendMode {
        public BlendMode source, destination;
    }

    public FinalBlendMode finalBlendMode = new FinalBlendMode {
        source = BlendMode.One,
        destination = BlendMode.Zero
    };
}

我们不能直接将这些设置添加到 Camera 组件,所以我们将创建一个补充的 CustomRenderPipelineCamera 组件。它只能添加一次到作为摄像机的游戏对象上,并且只能添加一次。给它一个 CameraSettings 配置字段和相应的 getter 属性。因为设置是一个类,属性必须确保它存在,所以如果需要,创建一个新的设置对象实例。如果组件尚未被编辑器序列化,或者在运行时将其添加到摄像机后,就会出现这种情况。

1
2
3
4
5
6
7
8
9
10
using UnityEngine;

[DisallowMultipleComponent, RequireComponent(typeof(Camera))]
public class CustomRenderPipelineCamera : MonoBehaviour {

    [SerializeField]
    CameraSettings settings = default;

    public CameraSettings Settings => settings ?? (settings = new CameraSettings());
}

现在我们可以在 CameraRenderer.Render 开始时获取摄像机的 CustomRenderPipelineCamera 组件。为了支持没有自定义设置的摄像机,我们将检查我们的组件是否存在。如果存在,我们使用它的设置,否则我们将使用我们创建一次并在静态字段中存储引用的默认设置对象。然后我们在设置堆栈时传递最终混合模式。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static CameraSettings defaultCameraSettings = new CameraSettings();
....
public void Render (....) {
    this.context = context;
    this.camera = camera;

    var crpCamera = camera.GetComponent<CustomRenderPipelineCamera>();
    CameraSettings cameraSettings =
        crpCamera ? crpCamera.Settings : defaultCameraSettings;
    ....
    postFXStack.Setup(
        context, camera, postFXSettings, useHDR, colorLUTResolution,
        cameraSettings.finalBlendMode
    );
    ....
}

PostFXStack 现在必须跟踪摄像机的最终混合模式。

1
2
3
4
5
6
7
8
9
CameraSettings.FinalBlendMode finalBlendMode;
....
public void Setup (
    ScriptableRenderContext context, Camera camera, PostFXSettings settings,
    bool useHDR, int colorLUTResolution, CameraSettings.FinalBlendMode finalBlendMode
) {
    this.finalBlendMode = finalBlendMode;
    ....
}

因此它可以在 DrawFinal 开始时设置新的 _FinalSrcBlend_FinalDstBlend 浮点着色器属性。另外,如果目标混合模式不是零,我们现在也总是需要加载目标缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int
    finalSrcBlendId = Shader.PropertyToID("_FinalSrcBlend"),
    finalDstBlendId = Shader.PropertyToID("_FinalDstBlend");
....
void DrawFinal (RenderTargetIdentifier from) {
    buffer.SetGlobalFloat(finalSrcBlendId, (float)finalBlendMode.source);
    buffer.SetGlobalFloat(finalDstBlendId, (float)finalBlendMode.destination);
    buffer.SetGlobalTexture(fxSourceId, from);
    buffer.SetRenderTarget(
        BuiltinRenderTextureType.CameraTarget,
        finalBlendMode.destination == BlendMode.Zero && camera.rect == fullViewRect ?
            RenderBufferLoadAction.DontCare : RenderBufferLoadAction.Load,
        RenderBufferStoreAction.Store
    );
    ....
}

最后,在最终通道中使用新属性而不是硬编码的混合模式。

1
2
Name "Final"
Blend [_FinalSrcBlend] [_FinalDstBlend]

从现在开始,没有我们设置的摄像机将覆盖目标缓冲区的内容,这是由于默认的 One Zero 最终混合模式。叠加摄像机必须给定不同的最终混合模式,通常是 One OneMinusSrcAlpha

叠加摄像机的设置组件
叠加摄像机的设置组件

渲染纹理

除了创建分屏显示或直接分层摄像机外,通常还使用摄像机进行游戏内显示或作为 GUI 的一部分。在这些情况下,摄像机的目标必须是渲染纹理,可以是资源或在运行时创建的。作为示例,我通过 Assets / Create / Render Texture 创建了一个 200×100 的渲染纹理。我没有给它深度缓冲区,因为我将带有后处理效果的摄像机渲染到它,它会创建自己的带有深度缓冲区的中间渲染纹理。

渲染纹理资源
渲染纹理资源

然后我创建了一个将场景渲染到此纹理的摄像机,通过将其连接到摄像机的 Target Texture 属性。

摄像机目标纹理设置
摄像机目标纹理设置

与常规渲染一样,底部摄像机必须为其最终混合模式使用 One Zero。编辑器最初会呈现一个清晰的黑色纹理,但之后渲染纹理将包含上次渲染到它的任何内容。多个摄像机可以像正常一样使用任何视口渲染到同一渲染纹理。唯一的区别是 Unity 会自动在渲染到显示器的摄像机之前渲染具有渲染纹理目标的摄像机。首先按深度递增顺序渲染具有目标纹理的摄像机,然后是没有目标纹理的摄像机。

Unity UI

渲染纹理可以像任何常规纹理一样使用。要通过 Unity 的 UI 显示它,我们必须使用带有原始图像组件的游戏对象,通过 GameObject / UI / Raw Image 创建。

UI 原始图像,部分与按钮重叠
UI 原始图像,部分与按钮重叠

原始图像使用默认的 UI 材质,它执行标准的 SrcAlpha OneMinusSrcAlpha 混合。因此透明度有效,但泛光不是加法的,除非纹理以像素完美显示,否则双线性过滤会使摄像机的黑色背景颜色在透明边缘周围显示为深色轮廓。

游戏中的原始图像显示
游戏中的原始图像显示

为了支持其他混合模式,我们必须创建一个自定义 UI 着色器。我们只需通过复制 Default-UI 着色器来实现这一点,通过 _SrcBlend_DstBlend 着色器属性添加对可配置混合的支持。我还调整了着色器代码以更好地匹配本教程系列的风格。

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
Shader "Custom RP/UI Custom Blending" {
    Properties {
        [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
        _Color ("Tint", Color) = (1,1,1,1)

        _StencilComp ("Stencil Comparison", Float) = 8
        _Stencil ("Stencil ID", Float) = 0
        _StencilOp ("Stencil Operation", Float) = 0
        _StencilWriteMask ("Stencil Write Mask", Float) = 255
        _StencilReadMask ("Stencil Read Mask", Float) = 255

        _ColorMask ("Color Mask", Float) = 15

        [Toggle(UNITY_UI_ALPHACLIP)] _UseUIAlphaClip ("Use Alpha Clip", Float) = 0

        [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Src Blend", Float) = 1
        [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Dst Blend", Float) = 0
    }

    SubShader {
        Tags {
            "Queue" = "Transparent"
            "IgnoreProjector" = "True"
            "RenderType" = "Transparent"
            "PreviewType" = "Plane"
            "CanUseSpriteAtlas" = "True"
        }

        Stencil {
            Ref [_Stencil]
            Comp [_StencilComp]
            Pass [_StencilOp]
            ReadMask [_StencilReadMask]
            WriteMask [_StencilWriteMask]
        }

        Blend [_SrcBlend] [_DstBlend]
        ColorMask [_ColorMask]

        Cull Off
        ZWrite Off
        ZTest [unity_GUIZTestMode]

        Pass { .... }
    }
}

这是通道,除了风格外未做修改:

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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
Pass {
    Name "Default"

    CGPROGRAM
    #pragma vertex UIPassVertex
    #pragma fragment UIPassFragment
    #pragma target 2.0

    #include "UnityCG.cginc"
    #include "UnityUI.cginc"

    #pragma multi_compile_local _ UNITY_UI_CLIP_RECT
    #pragma multi_compile_local _ UNITY_UI_ALPHACLIP

    struct Attributes {
        float4 positionOS : POSITION;
        float4 color : COLOR;
        float2 baseUV : TEXCOORD0;
        UNITY_VERTEX_INPUT_INSTANCE_ID
    };

    struct Varyings {
        float4 positionCS : SV_POSITION;
        float2 positionUI : VAR_POSITION;
        float2 baseUV : VAR_BASE_UV;
        float4 color : COLOR;
        UNITY_VERTEX_OUTPUT_STEREO
    };

    sampler2D _MainTex;
    float4 _MainTex_ST;
    float4 _Color;
    float4 _TextureSampleAdd;
    float4 _ClipRect;

    Varyings UIPassVertex (Attributes input) {
        Varyings output;
        UNITY_SETUP_INSTANCE_ID(input);
        UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output);
        output.positionCS = UnityObjectToClipPos(input.positionOS);
        output.positionUI = input.positionOS.xy;
        output.baseUV = TRANSFORM_TEX(input.baseUV, _MainTex);
        output.color = input.color * _Color;
        return output;
    }

    float4 UIPassFragment (Varyings input) : SV_Target {
        float4 color =
            (tex2D(_MainTex, input.baseUV) + _TextureSampleAdd) * input.color;

        #if defined(UNITY_UI_CLIP_RECT)
            color.a *= UnityGet2DClipping(input.positionUI, _ClipRect);
        #endif

        #if defined(UNITY_UI_ALPHACLIP)
            clip (color.a - 0.001);
        #endif

        return color;
    }
    ENDCG
}
使用预乘 alpha 混合的自定义 UI 着色器的原始 UI 图像
使用预乘 alpha 混合的自定义 UI 着色器的原始 UI 图像

每个摄像机的后处理设置

在使用多个摄像机时,应该可以为每个摄像机使用不同的后处理效果,所以让我们添加对它的支持。给 CameraSettings 添加一个切换开关来控制它是否覆盖全局后处理设置,以及它自己的 PostFXSettings 字段。

1
2
public bool overridePostFX = false;
public PostFXSettings postFXSettings = default;
摄像机后处理覆盖设置
摄像机后处理覆盖设置

CameraRenderer.Render 检查摄像机是否覆盖后处理设置。如果是这样,用摄像机的设置替换渲染管线提供的设置。

1
2
3
4
5
6
var crpCamera = camera.GetComponent<CustomRenderPipelineCamera>();
CameraSettings cameraSettings =
    crpCamera ? crpCamera.Settings : defaultCameraSettings;
if (cameraSettings.overridePostFX) {
    postFXSettings = cameraSettings.postFXSettings;
}

现在每个摄像机都可以使用默认或自定义后处理效果。例如,我让底部摄像机使用默认设置,为叠加摄像机关闭后处理效果,并为渲染纹理摄像机提供不同的后处理效果,具有冷色温移位和中性色调映射。

每个摄像机的不同后处理设置
每个摄像机的不同后处理设置

渲染层

当同时显示多个摄像机视图时,我们并不总是希望为所有摄像机渲染相同的场景。例如,我们可以渲染主视图和角色肖像。Unity 一次只支持一个全局场景,因此我们必须使用一种方法来限制每个摄像机看到的内容。

剔除遮罩

每个游戏对象都属于一个单一的层。场景窗口可以通过编辑器右上角的 Layers 下拉菜单过滤它显示的层。同样,每个摄像机都有一个 Culling Mask 属性,可以用同样的方式限制它显示的内容。此遮罩在渲染的剔除步骤中应用。

每个对象恰好属于一个层,而剔除遮罩可以包含多个层。例如,你可以有两个摄像机都渲染 Default 层,而其中一个还渲染 Ignore Raycasts,另一个则还渲染 Water。因此,一些对象为两个摄像机显示,而其他对象只对其中一个可见,还有其他对象可能根本不会被渲染。

每个摄像机具有不同剔除遮罩的分屏
每个摄像机具有不同剔除遮罩的分屏

光源也有剔除遮罩。其想法是,为光源剔除的对象表现得好像该光源不存在。该对象不受光源照明,也不为其投射阴影。但如果我们用方向光尝试这个,只有它的阴影受到影响。

应用于方向光的剔除遮罩仅影响阴影
应用于方向光的剔除遮罩仅影响阴影

如果我们用另一种光源类型尝试这个,如果我们的 RP 的 Use Lights Per Object 选项被禁用,也会发生同样的事情。

相同的剔除遮罩应用于明亮的点光源
相同的剔除遮罩应用于明亮的点光源

如果启用 Use Lights Per Object,则光源剔除可以正常工作,但仅适用于点光源和聚光灯。

启用逐对象光源的点光源
启用逐对象光源的点光源

我们得到这些结果是因为当 Unity 将逐对象光源索引发送到 GPU 时会应用光源的剔除遮罩。所以如果我们不使用这些,剔除就不起作用。对于方向光它永远不起作用,因为我们总是将它们应用于所有对象。阴影总是被正确剔除,因为在从光源的角度渲染阴影投射器时,光源的剔除遮罩像摄像机的一样使用。

我们不能用当前的方法完全支持光源的剔除遮罩。这个限制不是一个障碍,HDRP 也不支持光源的剔除遮罩。Unity 为 SRP 提供渲染层作为替代方案。使用渲染层而不是游戏对象层有两个好处。首先,渲染器不限于只有一个层,这使它们更加灵活。其次,渲染层不用于任何其他用途,不像默认层也用于物理。

在我们转向渲染层之前,让我们在光源的剔除遮罩设置为 Everything 以外的内容时在光源的检查器中显示警告。光源的剔除遮罩通过其 cullingMask 整数属性可用,-1 表示所有层。如果 CustomLightEditor 的目标将其遮罩设置为其他任何内容,则在 OnInspectorGUI 末尾调用 EditorGUILayout.HelpBox,使用指示剔除遮罩仅影响阴影的字符串和 MessageType.Warning 来显示警告图标。

1
2
3
4
5
6
7
8
9
10
public override void OnInspectorGUI() {
    ....
    var light = target as Light;
    if (light.cullingMask != -1) {
        EditorGUILayout.HelpBox(
            "Culling Mask only affects shadows.",
            MessageType.Warning
        );
    }
}
光源的剔除遮罩警告
光源的剔除遮罩警告

我们可以更具体一些,提到 Use Lights Per Object 设置对非方向光有影响。

1
2
3
4
5
6
EditorGUILayout.HelpBox(
    light.type == LightType.Directional ?
        "Culling Mask only affects shadows." :
        "Culling Mask only affects shadow unless Use Lights Per Objects is on.",
    MessageType.Warning
);

调整渲染层遮罩

当使用 SRP 时,光源和 MeshRenderer 组件的检查器会显示 Rendering Layer Mask 属性,该属性在使用默认 RP 时是隐藏的。

MeshRenderer 的渲染层遮罩
MeshRenderer 的渲染层遮罩

默认情况下,下拉菜单显示 32 层,名为 Layer1、Layer2 等。这些层的名称可以通过覆盖 RenderPipelineAsset.renderingLayerMaskNames getter 属性为每个 RP 配置。由于这纯粹是下拉菜单的修饰性质,我们只需要为 Unity 编辑器执行此操作。所以将 CustomRenderPipelineAsset 转换为分部类。

1
public partial class CustomRenderPipelineAsset : RenderPipelineAsset { .... }

然后为它创建一个仅编辑器脚本资源,覆盖该属性。它返回一个字符串数组,我们可以在静态构造函数方法中创建它。我们将从与默认值相同的名称开始,除了在 Layer 词和数字之间有一个空格。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
partial class CustomRenderPipelineAsset {

#if UNITY_EDITOR

    static string[] renderingLayerNames;

    static CustomRenderPipelineAsset () {
        renderingLayerNames = new string[32];
        for (int i = 0; i < renderingLayerNames.Length; i++) {
            renderingLayerNames[i] = "Layer " + (i + 1);
        }
    }

    public override string[] renderingLayerMaskNames => renderingLayerNames;

#endif
}

这稍微改变了渲染层标签。它对 MeshRenderer 组件工作良好,但不幸的是光源的属性不响应更改。渲染层下拉菜单显示,但调整不会应用。我们不能直接修复这个问题,但可以添加我们自己的属性版本以便工作。首先在 CustomLightEditor 中为它创建一个 GUIContent,具有相同的标签和工具提示,指示这是其上方属性的功能版本。

1
2
static GUIContent renderingLayerMaskLabel =
    new GUIContent("Rendering Layer Mask", "Functional version of above property.");

然后创建一个 DrawRenderingLayerMask 方法,它是 LightEditor.DrawRenderingLayerMask 的替代方法,它确实将更改的值分配回属性。为了使下拉菜单使用 RP 的层名称,我们不能简单地依赖 EditorGUILayout.PropertyField。我们必须从设置中获取相关属性,确保处理多选的混合值,将遮罩作为整数获取,显示它,并将更改的值分配回属性。这是默认光源检查器版本中缺少的最后一步。

显示下拉菜单是通过调用 EditorGUILayout.MaskField 完成的,参数为标签、遮罩和 GraphicsSettings.currentRenderPipeline.renderingLayerMaskNames

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void DrawRenderingLayerMask () {
    SerializedProperty property = settings.renderingLayerMask;
    EditorGUI.showMixedValue = property.hasMultipleDifferentValues;
    EditorGUI.BeginChangeCheck();
    int mask = property.intValue;
    mask = EditorGUILayout.MaskField(
        renderingLayerMaskLabel, mask,
        GraphicsSettings.currentRenderPipeline.renderingLayerMaskNames
    );
    if (EditorGUI.EndChangeCheck()) {
        property.intValue = mask;
    }
    EditorGUI.showMixedValue = false;
}

在调用 base.OnInspectorGUI 之后直接调用新方法,以便在非功能性属性正下方显示额外的 Rendering Layer Mask 属性。此外,我们现在必须始终调用 ApplyModifiedProperties 以确保将渲染层遮罩的更改应用于光源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public override void OnInspectorGUI() {
    base.OnInspectorGUI();
    DrawRenderingLayerMask();

    if (
        !settings.lightType.hasMultipleDifferentValues &&
        (LightType)settings.lightType.enumValueIndex == LightType.Spot
    )
    {
        settings.DrawInnerAndOuterSpotAngle();
        //settings.ApplyModifiedProperties();
    }

    settings.ApplyModifiedProperties();
    ....
}
光源的额外渲染层遮罩属性
光源的额外渲染层遮罩属性

我们版本的属性确实应用了更改,除了选择 Everything 或 Layer 32 选项会产生与选择 Nothing 相同的结果。这是因为光源的渲染层遮罩在内部存储为无符号整数,一个 uint。这是有道理的,因为它用作位掩码,但 SerializedProperty 仅支持获取和设置有符号整数值。

Everything 选项由 -1 表示,属性将其限制为零。Layer 32 对应于最高位,它表示一个比 int.MaxValue 大 1 的数字,属性也将其替换为零。

我们可以通过简单地删除最后一层来解决第二个问题,将渲染层名称的数量减少到 31。这仍然是很多层。HDRP 仅支持八个。

1
renderingLayerNames = new string[31];

通过删除一层,Everything 选项现在由除最高位之外的所有位都设置的值表示,这与 int.MaxValue 匹配。因此我们可以通过在存储 int.MaxValue 时显示 -1 来解决第一个问题。默认属性不这样做,这就是为什么它在适当时显示 Mixed… 而不是 Everything。HDRP 也遭受这个问题。

1
2
3
4
5
6
7
8
9
10
11
int mask = property.intValue;
if (mask == int.MaxValue) {
    mask = -1;
}
mask = EditorGUILayout.MaskField(
    renderingLayerMaskLabel, mask,
    GraphicsSettings.currentRenderPipeline.renderingLayerMaskNames
);
if (EditorGUI.EndChangeCheck()) {
    property.intValue = mask == -1 ? int.MaxValue : mask;
}
功能性渲染层遮罩属性
功能性渲染层遮罩属性

我们最终可以正确调整光源的渲染层遮罩属性。但默认情况下不使用遮罩,所以什么都没有改变。我们可以通过在 Shadows 中启用 ShadowDrawingSettingsuseRenderingLayerMaskTest 将其应用于阴影。对所有光源执行此操作,因此在 RenderDirectionalShadowsRenderSpotShadowsRenderPointShadows 中。我们现在可以通过配置对象和光源的渲染层遮罩来消除阴影。

1
2
3
4
5
var shadowSettings = new ShadowDrawingSettings(
    ....
) {
    useRenderingLayerMaskTest = true
};

将遮罩发送到 GPU

要将渲染层遮罩应用于我们的 Lit 着色器的光照计算,对象和光源的遮罩都必须在 GPU 端可用。要访问对象的遮罩,我们必须在 UnityInput 中的 UnityPerDraw 结构中添加一个 float4 unity_RenderingLayer 字段,直接在 unity_WorldTransformParams 下方。遮罩存储在其第一个分量中。

1
2
real4 unity_WorldTransformParams;
float4 unity_RenderingLayer;

我们将遮罩添加到我们的 Surface 结构中,作为 uint,因为它是位掩码。

1
2
3
4
struct Surface {
    ....
    uint renderingLayerMask;
};

LitPassFragment 中设置表面的遮罩时,我们必须使用 asuint 内部函数。这为我们提供原始数据,而不执行从 float 到 uint 的数值类型转换,这会改变位模式。

1
2
surface.dither = InterleavedGradientNoise(input.positionCS.xy, 0);
surface.renderingLayerMask = asuint(unity_RenderingLayer.x);

我们必须对 Light 结构执行相同的操作,因此也为其提供一个用于其渲染层遮罩的 uint 字段。

1
2
3
4
struct Light {
    ....
    uint renderingLayerMask;
};

我们负责将遮罩发送到 GPU。让我们通过将其存储在 _DirectionalLightDirections_OtherLightDirections 数组的未使用的第四个分量中来实现这一点。为了清晰起见,将 AndMasks 后缀添加到它们的名称中。

1
2
3
4
5
6
7
CBUFFER_START(_CustomLight)
    ....
    float4 _DirectionalLightDirectionsAndMasks[MAX_DIRECTIONAL_LIGHT_COUNT];
    ....
    float4 _OtherLightDirectionsAndMasks[MAX_OTHER_LIGHT_COUNT];
    ....
CBUFFER_END

GetDirectionalLight 中复制遮罩。

1
2
light.direction = _DirectionalLightDirectionsAndMasks[index].xyz;
light.renderingLayerMask = asuint(_DirectionalLightDirectionsAndMasks[index].w);

GetOtherLight 中也是如此。

1
2
float3 spotDirection = _OtherLightDirectionsAndMasks[index].xyz;
light.renderingLayerMask = asuint(_OtherLightDirectionsAndMasks[index].w);

在 CPU 端,调整我们的 Lighting 类中的标识符和数组名称以匹配。然后还复制光源的渲染层遮罩。我们从 SetupDirectionalLight 开始,它现在还需要直接访问 Light 对象。让我们将其添加为参数。

1
2
3
4
5
6
7
8
9
10
void SetupDirectionalLight (
    int index, int visibleIndex, ref VisibleLight visibleLight, Light light
) {
    dirLightColors[index] = visibleLight.finalColor;
    Vector4 dirAndMask = -visibleLight.localToWorldMatrix.GetColumn(2);
    dirAndMask.w = light.renderingLayerMask;
    dirLightDirectionsAndMasks[index] = dirAndMask;
    dirLightShadowData[index] =
        shadows.ReserveDirectionalShadows(light, visibleIndex);
}

SetupSpotLight 进行相同的更改,还添加 Light 参数以保持一致。

1
2
3
4
5
6
7
8
9
10
void SetupSpotLight (
    int index, int visibleIndex, ref VisibleLight visibleLight, Light light
) {
    ....
    Vector4 dirAndMask = -visibleLight.localToWorldMatrix.GetColumn(2);
    dirAndMask.w = light.renderingLayerMask;
    otherLightDirectionsAndMasks[index] = dirAndMask;
    //Light light = visibleLight.light;
    ....
}

然后对 SetupPointLight 执行此操作,它现在还必须更改 otherLightDirectionsAndMasks。由于它不使用方向,可以将其设置为零。

1
2
3
4
5
6
7
8
9
10
11
void SetupPointLight (
    int index, int visibleIndex, ref VisibleLight visibleLight, Light light
) {
    ....
    Vector4 dirAndmask = Vector4.zero;
    dirAndmask.w = light.renderingLayerMask;
    otherLightDirectionsAndMasks[index] = dirAndmask;
    //Light light = visibleLight.light;
    otherLightShadowData[index] =
        shadows.ReserveOtherShadows(light, visibleIndex);
}

现在我们必须在 SetupLights 中抓取 Light 对象一次并将其传递给所有设置方法。我们也会在这里稍后对光源做其他事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
VisibleLight visibleLight = visibleLights[i];
Light light = visibleLight.light;
switch (visibleLight.lightType) {
    case LightType.Directional:
        if (dirLightCount < maxDirLightCount) {
            SetupDirectionalLight(
                dirLightCount++, i, ref visibleLight, light
            );
        }
        break;
    case LightType.Point:
        if (otherLightCount < maxOtherLightCount) {
            newIndex = otherLightCount;
            SetupPointLight(otherLightCount++, i, ref visibleLight, light);
        }
        break;
    case LightType.Spot:
        if (otherLightCount < maxOtherLightCount) {
            newIndex = otherLightCount;
            SetupSpotLight(otherLightCount++, i, ref visibleLight, light);
        }
        break;
}

回到 GPU 端,在 Lighting 中添加一个 RenderingLayersOverlap 函数,返回表面和光源的遮罩是否重叠。这是通过检查位掩码的按位与是否非零来完成的。

1
2
3
bool RenderingLayersOverlap (Surface surface, Light light) {
    return (surface.renderingLayerMask & light.renderingLayerMask) != 0;
}

现在我们可以使用此方法在 GetLighting 的三个循环内检查是否需要添加光照。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for (int i = 0; i < GetDirectionalLightCount(); i++) {
    Light light = GetDirectionalLight(i, surfaceWS, shadowData);
    if (RenderingLayersOverlap(surfaceWS, light)) {
        color += GetLighting(surfaceWS, brdf, light);
    }
}

#if defined(_LIGHTS_PER_OBJECT)
    for (int j = 0; j < min(unity_LightData.y, 8); j++) {
        int lightIndex = unity_LightIndices[j / 4][j % 4];
        Light light = GetOtherLight(lightIndex, surfaceWS, shadowData);
        if (RenderingLayersOverlap(surfaceWS, light)) {
            color += GetLighting(surfaceWS, brdf, light);
        }
    }
#else
    for (int j = 0; j < GetOtherLightCount(); j++) {
        Light light = GetOtherLight(j, surfaceWS, shadowData);
        if (RenderingLayersOverlap(surfaceWS, light)) {
            color += GetLighting(surfaceWS, brdf, light);
        }
    }
#endif

将 Int 重新解释为 Float

尽管此时渲染遮罩影响光照,但它不能正确执行。Light.renderingLayerMask 属性将其位掩码公开为 int,并且在光源设置方法中转换为 float 时会被破坏。没有办法直接将整数数组发送到 GPU,所以我们必须以某种方式在不转换的情况下将 int 重新解释为 float,但 C# 中没有直接等效于 asuint 的函数。

我们不能像在 HLSL 中那样简单地在 C# 中重新解释数据,因为 C# 是强类型的。我们可以做的是通过使用联合结构来别名数据类型。我们将通过向 int 添加 ReinterpretAsFloat 扩展方法来隐藏这种方法。为此方法创建一个静态 ReinterpretExtensions 类,它最初只执行常规类型转换。

1
2
3
4
5
6
public static class ReinterpretExtensions {

    public static float ReinterpretAsFloat (this int value) {
        return value;
    }
}

在三个光源设置方法中使用 ReinterpretAsFloat 而不是依赖隐式转换。

1
dirAndMask.w = light.renderingLayerMask.ReinterpretAsFloat();

然后在 ReinterpretExtensions 内部定义一个结构类型,具有 int 和 float 字段。在 ReinterpretAsFloat 中初始化此类型的默认变量,设置其整数值,然后返回其浮点值。

1
2
3
4
5
6
7
8
9
10
struct IntFloat {
    public int intValue;
    public float floatValue;
}

public static float ReinterpretAsFloat (this int value) {
    IntFloat converter = default;
    converter.intValue = value;
    return converter.floatValue;
}

要将其转换为重新解释,我们必须使结构的两个字段重叠,以便它们共享相同的数据。这是可能的,因为两种类型的大小都是四个字节。我们通过使结构的布局显式来实现这一点,通过将 StructLayout 属性附加到类型,设置为 LayoutKind.Explicit。然后我们必须将 FieldOffset 属性添加到其字段以指示字段的数据应放置在何处。将两个偏移量都设置为零,以便它们重叠。这些属性来自 System.Runtime.InteropServices 命名空间。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Runtime.InteropServices;

public static class ReinterpretExtensions {

    [StructLayout(LayoutKind.Explicit)]
    struct IntFloat {

        [FieldOffset(0)]
        public int intValue;

        [FieldOffset(0)]
        public float floatValue;
    }

    ....
}

现在结构的 int 和 float 字段表示相同的数据,但以不同方式解释。这保持位掩码完整,渲染层遮罩现在可以正确工作。

方向光忽略一半对象
方向光忽略一半对象

摄像机渲染层遮罩

除了使用现有的剔除遮罩外,我们还可以使用渲染层遮罩来限制摄像机渲染的内容。Camera 没有渲染层遮罩属性,但我们可以将其添加到 CameraSettings。我们将使其为 int,因为光源的遮罩也作为 int 公开。默认设置为 -1,表示所有层。

1
public int renderingLayerMask = -1;
摄像机渲染层遮罩,作为整数公开
摄像机渲染层遮罩,作为整数公开

要将遮罩公开为下拉菜单,我们必须为其创建自定义 GUI。但与其为整个 CameraSettings 类创建自定义编辑器,不如仅为渲染层遮罩创建一个。

首先,要指示字段表示渲染层遮罩,创建一个扩展 PropertyAttributeRenderingLayerMaskFieldAttribute 类。这只是一个标记属性,不需要做其他任何事情。请注意,这不是编辑器类型,因此不应放在 Editor 文件夹中。

1
2
3
using UnityEngine;

public class RenderingLayerMaskFieldAttribute : PropertyAttribute {}

将此属性附加到我们的渲染层遮罩字段。

1
2
[RenderingLayerMaskField]
public int renderingLayerMask = -1;

现在创建一个扩展 PropertyDrawer 的自定义属性绘制器编辑器类,为我们的属性类型使用 CustomPropertyDrawer 属性。将 CustomLightEditor.DrawRenderingLayerMask 复制到其中,将其重命名为 Draw,并将其设为公共静态。然后给它三个参数:位置 Rect、序列化属性和 GUIContent 标签。使用这些来调用 EditorGUI.MaskField 而不是 EditorGUILayout.MaskField

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
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;

[CustomPropertyDrawer(typeof(RenderingLayerMaskFieldAttribute))]
public class RenderingLayerMaskDrawer : PropertyDrawer {

    public static void Draw (
        Rect position, SerializedProperty property, GUIContent label
    ) {
        //SerializedProperty property = settings.renderingLayerMask;
        EditorGUI.showMixedValue = property.hasMultipleDifferentValues;
        EditorGUI.BeginChangeCheck();
        int mask = property.intValue;
        if (mask == int.MaxValue) {
            mask = -1;
        }
        mask = EditorGUI.MaskField(
            position, label, mask,
            GraphicsSettings.currentRenderPipeline.renderingLayerMaskNames
        );
        if (EditorGUI.EndChangeCheck()) {
            property.intValue = mask == -1 ? int.MaxValue : mask;
        }
        EditorGUI.showMixedValue = false;
    }
}

我们只需要在属性的基础类型是 uint 时单独处理 -1。如果其 type 属性等于 “uint”,则是这种情况。

1
2
3
4
5
6
7
8
9
int mask = property.intValue;
bool isUint = property.type == "uint";
if (isUint && mask == int.MaxValue) {
    mask = -1;
}
....
if (EditorGUI.EndChangeCheck()) {
    property.intValue = isUint && mask == -1 ? int.MaxValue : mask;
}

然后覆盖 OnGUI 方法,简单地将其调用转发给 Draw

1
2
3
4
5
public override void OnGUI (
    Rect position, SerializedProperty property, GUIContent label
) {
    Draw(position, property, label);
}
渲染层遮罩下拉菜单
渲染层遮罩下拉菜单

为了使 Draw 更易于使用,添加一个没有 Rect 参数的版本。调用 EditorGUILayout.GetControlRect 从布局引擎获取单行位置矩形。

1
2
3
public static void Draw (SerializedProperty property, GUIContent label) {
    Draw(EditorGUILayout.GetControlRect(), property, label);
}

现在我们可以从 CustomLightEditor 中删除 DrawRenderingLayerMask 方法并调用 RenderingLayerMaskDrawer.Draw

1
2
3
4
5
6
7
8
9
10
public override void OnInspectorGUI() {
    base.OnInspectorGUI();
    //DrawRenderingLayerMask();
    RenderingLayerMaskDrawer.Draw(
        settings.renderingLayerMask, renderingLayerMaskLabel
    );
    ....
}

//void DrawRenderingLayerMask () { .... }

要应用摄像机的渲染层遮罩,为 CameraRenderer.DrawVisibleGeometry 添加一个参数,并将其作为名为 renderingLayerMask 的参数传递给 FilteringSettings 构造函数方法,转换为 uint。

1
2
3
4
5
6
7
8
9
10
void DrawVisibleGeometry (
    bool useDynamicBatching, bool useGPUInstancing, bool useLightsPerObject,
    int renderingLayerMask
) {
    ....
    var filteringSettings = new FilteringSettings(
        RenderQueueRange.opaque, renderingLayerMask: (uint)renderingLayerMask
    );
    ....
}

然后在 Render 中调用 DrawVisibleGeometry 时传递渲染层遮罩。

1
2
3
4
DrawVisibleGeometry(
    useDynamicBatching, useGPUInstancing, useLightsPerObject,
    cameraSettings.renderingLayerMask
);

现在可以使用更灵活的渲染层遮罩来控制摄像机渲染的内容。例如,我们可以让一些对象投射阴影,即使摄像机看不到它们,而不需要特殊的仅阴影对象。

仅渲染不受光源影响的对象,加上地面
仅渲染不受光源影响的对象,加上地面

要记住的一件事是,只有剔除遮罩用于剔除,所以如果你排除很多对象,常规剔除遮罩将表现更好。

为每个摄像机遮罩光源

尽管 Unity 的 RP 不这样做,但除了几何体外,还可以为每个摄像机遮罩光源。我们将再次使用渲染层来实现这一点,但因为这是非标准行为,让我们通过向 CameraSettings 添加一个切换开关来使其可选。

1
public bool maskLights = false;
摄像机设置为遮罩光源
摄像机设置为遮罩光源

我们需要做的就是在 Lighting.SetupLights 中跳过遮罩光源。为此向方法添加一个渲染层遮罩参数,然后检查每个光源的渲染层遮罩是否与提供的遮罩重叠。如果是这样,继续执行 switch 语句来设置光源,否则跳过它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void SetupLights (bool useLightsPerObject, int renderingLayerMask) {
    ....
    for (i = 0; i < visibleLights.Length; i++) {
        int newIndex = -1;
        VisibleLight visibleLight = visibleLights[i];
        Light light = visibleLight.light;
        if ((light.renderingLayerMask & renderingLayerMask) != 0) {
            switch (visibleLight.lightType) {
                ....
            }
        }

        if (useLightsPerObject) {
            indexMap[i] = newIndex;
        }
    }
    ....
}

Lighting.Setup 必须传递渲染层遮罩。

1
2
3
4
5
6
7
8
public void Setup (
    ScriptableRenderContext context, CullingResults cullingResults,
    ShadowSettings shadowSettings, bool useLightsPerObject, int renderingLayerMask
) {
    ....
    SetupLights(useLightsPerObject, renderingLayerMask);
    ....
}

我们必须在 CameraRenderer.Render 中提供摄像机的遮罩,但仅当它适用于光源时,否则使用 -1。

1
2
3
4
lighting.Setup(
    context, cullingResults, shadowSettings, useLightsPerObject,
    cameraSettings.maskLights ? cameraSettings.renderingLayerMask : -1
);

现在我们可以做一些事情,比如让两个摄像机渲染相同的场景,但使用不同的光照,而不必在它们之间调整光源。这也使得在世界原点渲染单独的场景(如角色肖像)变得容易,而不会受到主场景光照的影响。请注意,这仅适用于实时光照,完全烘焙的光和混合光的烘焙间接贡献无法遮罩。

两个摄像机在不同光照下看到相同场景
两个摄像机在不同光照下看到相同场景

下一个教程是 Particles

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