Custom SRP - 14 Multiple Cameras

https://catlikecoding.com/unity/tutorials/custom-srp/multiple-cameras/

多摄像机混合与渲染层级

  • 用不同的 post FX settings 渲染多个摄像机

  • Layer cameras with custom blending

  • Support rendering layer masks

  • Mask lights per camera

1 Combining Cameras

每个摄像机都要处理裁剪,光照,阴影渲染,因此每一帧渲染的摄像机越少越好,最好是只有一个。但有时确实需要同时在不同的视角下进行渲染。例如分屏的多人游戏,后视镜,俯视图,游戏内的摄像机,3D角色展示等。

1.1 Split Screen

让我们从分屏应用开始,由两个并排的摄像机组成。左边的摄像机的 viewport 的 width 是 0.5,右边的摄像机 viewport width 也是 0.5,同时 x postion 也是 0.5。当没有后效时,一切看起来就是我们想要的

当启用后效,就会出问题,两个摄像机都用正确的尺寸进行了渲染,但是最后覆盖到整个屏幕(frame buffer),因此只能看到第二个摄像机的渲染。

这是因为调用 SetRenderTarget 时,会重置 viewport,因此,我们只需要在 post FX 最后渲染到 frame buffer 时,调用 SetViewport 设置正确的 viewport 即能解决该问题,为此我们定义新的 DrawFinal 来完成最后的上屏渲染:

cs 复制代码
void DoColorGradingAndToneMapping(int sourceId)
{
    ...
    //Draw(sourceId, BuiltinRenderTextureType.CameraTarget, Pass.Final);
    DrawFinal(sourceId);
    buffer.ReleaseTemporaryRT(colorGradingLUTId);
}

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);
}

如果用的是 tile-based GPU,某些平台下,在 viewport 周围会有一些错误的像素,超出边界。这是因为标记出的 tile regions 中有垃圾数据。如果 viewport 不是完整的,我们设置 RT 是要求加载该 RT 来解决。如果没遇到该问题则可以忽略

cs 复制代码
static Rect fullViewPort = new Rect(0f, 0f, 1f, 1f);

void DrawFinal(RenderTargetIdentifier from)
{
    buffer.SetGlobalTexture(fxSourceID, from);
    buffer.SetRenderTarget(BuiltinRenderTextureType.CameraTarget,
        camera.pixelRect == fullViewPort ? RenderBufferLoadAction.DontCare : RenderBufferLoadAction.Load, 
        RenderBufferStoreAction.Store);
    buffer.SetViewport(camera.pixelRect);
    buffer.DrawProcedural(Matrix4x4.identity, settings.Material, (int)Pass.Final, MeshTopology.Triangles, 3);
}

1.2 Layering Cameras

除了渲染到不同的区域,还可以让摄像机叠加渲染。最简单的例子是让第一个摄像机正常渲染。第二个摄像机使用较小的 viewport ,比如设置其尺寸为 0.5,并且设置其 xy 值 为 0.25,使其渲染在中间:

如果没有开启后效,通过设置可以让上面的摄像机仅 clear depth,这样下面的部分就能显示出来了,某些叠加渲染可能会有这样的需求。

但是如果开启了后效,那么就又不对了,因为后效渲染时时,强制设置了 CameraClearFlags.Color。

解决该问题,首先,设置 final pass 为 alpha 混合模式,并且在 set render target 是,load action 使用 load

cs 复制代码
Pass
{
    Name "Final Pass"

    Blend SrcAlpha OneMinusSrcAlpha

    HLSLPROGRAM
    #pragma target 3.5
    #pragma vertex DefaultPassVertex
    #pragma fragment FinalPassFragment
    ENDHLSL
}


void DrawFinal(RenderTargetIdentifier from)
{
    buffer.SetGlobalTexture(fxSourceID, from);
    //RenderBufferLoadAction loadAction = camera.pixelRect == fullViewPort ? RenderBufferLoadAction.DontCare : RenderBufferLoadAction.Load;
    RenderBufferLoadAction loadAction = RenderBufferLoadAction.Load;   // 总是 load
    buffer.SetRenderTarget(BuiltinRenderTextureType.CameraTarget,
        loadAction,
        RenderBufferStoreAction.Store);
    buffer.SetViewport(camera.pixelRect);
    buffer.DrawProcedural(Matrix4x4.identity, settings.Material, (int)Pass.Final, MeshTopology.Triangles, 3);
}

现在设置上层摄像机的 clear color 的 alpha 为 0(Cmera.ClearFlags 设置为 solid color 后的 background 颜色),关闭 bloom(设置迭代次数为0),则可以混合下层摄像机。但是如果开启 bloom 依然还是不对,如下图

原因是目前的 bloom 没有保留透明度,通过在 bloom 的 final pass 中,保留高分辨率图的透明度来解决。需要修改两种模式: BloomAddPassFragment 和 BloomScatterFinalPassFragment。

cs 复制代码
float4 BloomAddPassFragment(Varyings input) : SV_TARGET
{
    float3 lowRes;
    if (_BloomBicubicUpsampling)
        lowRes = GetSourceBicubic(input.screenUV).rgb;
    else 
        lowRes = GetSource(input.screenUV).rgb;
    float4 highRes = GetSource2(input.screenUV);
    return float4(lowRes * _BloomIntensity + highRes.rgb, highRes.a);
}

float4 BloomScatterFinalPassFragment(Varyings input) : SV_TARGET
{
    float3 lowRes;
    if (_BloomBicubicUpsampling)
        lowRes = GetSourceBicubic(input.screenUV).rgb;
    else
        lowRes = GetSource(input.screenUV).rgb;
    float4 highRes = GetSource2(input.screenUV);
    lowRes += highRes.rgb - ApplyBloomThreshold(highRes.rgb);
    return float4(lerp(highRes.rgb, lowRes, _BloomIntensity), highRes.a);
}

现在开启 bloom 时半透明也能正常工作了,但是 bloom 在透明区域消失了。可以通过将 final pass 的透明模式改为 premultiplied alpha blending 来解决,这同时需要设置摄像机的 solid color 为黑色,因为该颜色会和下面的摄像机的渲染叠加。

cs 复制代码
Pass
{
    Name "Final Pass"

    Blend One OneMinusSrcAlpha

    HLSLPROGRAM
    #pragma target 3.5
    #pragma vertex DefaultPassVertex
    #pragma fragment FinalPassFragment
    ENDHLSL
}

1.3 Layered Alpha

当前分层渲染的方法,只有在我们的 shader 输出一个合理的 alpha 给摄像机用来混合时,才是正确的。因为没有用到,因此我们不关心之前写入的 alpha 值。如果有两个alpha 都是 0.5 的立方体渲染到同一个像素,那么该像素的 alpha 值将会是 0.25。同时如果在同一个像素上,有任何一次渲染的 alpha 是 1,那么最终也应该是 1;如果后面渲染的 alpha 是 0,那么应该保留之前的 alpha。通过设置 alpha 混合模式为 One OneMinusSrcAlpha 就可以解决该问题。修改 lit.shader 和 unlit.shader 文件:

cs 复制代码
// 逗号前是 color blend mode,逗号后是 alpha blend mode
Blend [_SrcBlend] [_DstBlend], One OneMinusSrcAlpha

当 alpha 值正常时,这种方法是可以工作的,也就是说只要像素写深度了,那么 alpha 一定是 1。对于不透明材质,这看起来没问题。但是如果材质的 BaseMap 贴图中,含有不同的 alpha 值,那么就会出错。对于 clip 材质,由于依赖 alpha 作为阈值进行裁剪,因此也会出错。如果像素被裁剪掉了就没问题,否则它的 alpha 应该是 1。

最简单的解决办法是将是否写 z 的标记,作为一个常量给 shader,当写 z 时,alpha 强制为 1。

首先为 LitInput 和 UnlitInput 定义 UnityPerMaterial 属性,并定义 GetAlpha 方法,在 lit/unlit pass fragment 返回时,调用 GetAlpha 确定 alpha 值:

cs 复制代码
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
    ...
    UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
    UNITY_DEFINE_INSTANCED_PROP(float, _ZWrite)
    ...
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)

...

float GetAlpha(float alpha)
{
    return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _ZWrite) ? 1.0 : alpha;
}

float4 LitPassFragment(Varyings input) : SV_TARGET
{
    ...
    return float4(color.rgb, GetAlpha(surface.alpha));
}

float4 UnlitPassFragment(Varyings input) : SV_TARGET
{
    ...
    return float4(base.rgb, GetAlpha(base.a));
}

1.4 Custom Blending

只有 overlay 摄像机混合到前面的摄像机才有合理性。底部的相机(应该是第一个渲染的相机?)将与相机Target的初始内容相融合,这些内容要么是随机的,要么是之前帧的累积,除非编辑器提供了一个已清空的目标(通常都会清空吧)。因此第一个相机应该使用 One Zero 混合模式。为支持替换,覆盖,以及更多特别的覆盖模式,当FX启用时,我们需要为摄像机增加最终混合模式的配置。创建可序列化的 CameraSettings 配置类,为了方便,在 FinalBlendMode 结构体中包装 源 和 目标 混合模式,然后设置默认值为 One Zero。

cs 复制代码
[Serializable]
public class CameraSettings
{
    [Serializable]
    public struct FinalBlendMode
    {
        public BlendMode source, destination;
    }

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

因为无法直接给 Camera 组件增加设置,因此需要定义新的组件来实现配置功能,该组件只能添加到有 Camera 组件的对象上,同时只能有一个该类型组件。定义 CameraSettings 属性,以及 getter。由于 CameraSettings 是类类型,因此在 getter 中根据需要进行创建。

cs 复制代码
[DisallowMultipleComponent, RequireComponent(typeof(Camera))]
public class CustomRenderPipelineCamera : MonoBehaviour
{
    [SerializeField]
    CameraSettings settings = default;

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

我们可以在 CameraRenderer.Render 实现开始的地方,获取 CustomRenderPipelineCamera 组件,如果没有添加该组件,则用默认配置对象,然后将 FinalBlendMode 传递给 PostFXStack:

cs 复制代码
// 默认的相机混合设置
static CameraSettings defaultCameraSettings = new CameraSettings();

public void Render(ScriptableRenderContext context, 
    Camera camera, 
    bool useDynamicBatching, 
    bool useGPUInstancing, 
    bool usePerObjectLights, 
    ShadowSettings shadows,
    PostFXSettings postFXSettings, 
    bool allowHDR,
    int colorLUTResolution
    )
{
    ...
    // 设置后处理相关参数
    var crpCamera = camera.GetComponent<CustomRenderPipelineCamera>();
    CameraSettings cameraSettings = crpCamera ? crpCamera.Settings : defaultCameraSettings;
    postFXStack.Setup(context, camera, postFXSettings, useHDR, cameraSettings.finalBlendMode);
    ...
}

PostFXStack 接收 FinalBlendMode 并存储下来

cs 复制代码
CameraSettings.FinalBlendMode finalBlendMode;
...
public void Setup(ScriptableRenderContext context, 
    Camera camera, PostFXSettings settings, bool useHDR,
    CameraSettings.FinalBlendMode finalBlendMode)
{
    this.context = context;
    this.camera = camera;
    this.useHDR = useHDR;
    this.finalBlendMode = finalBlendMode;
    // 只在Scene视图和Game视图中启用后效
    this.settings = camera.cameraType <= CameraType.SceneView ? settings : null;
    ApplySceneViewState();
}

然后,在 DrawFinal 的最后,设置 _FinalSrcBlend 和 _FinalDstBlend shader 属性。同时,如果目标混合模式不是 0,则需要加载 target

cs 复制代码
int finalSrcBlendId = Shader.PropertyToID("_FinalSrcBlend");
int finalDstBlendId = Shader.PropertyToID("_FinalDstBlend");
...
void DrawFinal(RenderTargetIdentifier from)
{
    buffer.SetGlobalFloat(finalSrcBlendId, (float)finalBlendMode.source);
    buffer.SetGlobalFloat(finalDstBlendId, (float)finalBlendMode.destination);
    buffer.SetGlobalTexture(fxSourceID, from);
    RenderBufferLoadAction loadAction = RenderBufferLoadAction.Load;
    if (finalBlendMode.destination == BlendMode.Zero && 
        camera.pixelRect == fullViewPort)
    {
        loadAction = RenderBufferLoadAction.DontCare;
    }
    buffer.SetRenderTarget(BuiltinRenderTextureType.CameraTarget,
        loadAction,
        RenderBufferStoreAction.Store);
    buffer.SetViewport(camera.pixelRect);
    buffer.DrawProcedural(Matrix4x4.identity, settings.Material, (int)Pass.Final, MeshTopology.Triangles, 3);
}

最后在pass定义中用属性来替换硬编码的混合模式

cs 复制代码
Pass
{
    Name "Final Pass"

    //Blend One OneMinusSrcAlpha
    Blend [_FinalSrcBlend] [_FinalDstBlend]

    HLSLPROGRAM
    #pragma target 3.5
    #pragma vertex DefaultPassVertex
    #pragma fragment FinalPassFragment
    ENDHLSL
}

现在,如果摄像机没有我们的配置组件,由于默认是 One Zero 混合模式,那么就会覆盖之前的渲染。上层的摄像机需要通过配置组件,设置需要的混合模式,典型的如 One OneMinusSrcAlpha

1.5 Render Texture

除了分屏和叠加摄像机,还有一种常用摄像机是游戏内显示,或用于UI显示的摄像机,这类情况需要将图像渲染到 render texture 上,render texture 可以是资产,也可以运行时动态创建。例如通过 Assets/Create/Render Texture 菜单功能,创建一个 200x100 的 render texture 。因为要用带后效的摄像机渲染,这会创建带有深度的中间 render texture,因此这个新建的 render textur 没有指定 depth buffer。

然后创建一个摄像机,渲染到上面的 render texture 上。

正常情况下,最下面的摄像机要设置为 One Zero 最终混合模式。编辑器初始时提供的是清理为黑色的 texture,但是之后,其内容就是最后一次渲染的结果了。多个相机可以渲染到同一个 render texture 上,同时可以设置不同的 viewport。唯一的不同是 Unity 会自动优先渲染那些渲染到 render texture 的相机,之后才是渲染到屏幕的相机,即,首先,具有目标纹理的摄像机会按照深度递增的顺序进行渲染,然后是那些没有纹理的摄像机。

1.6 Unity UI

Render Texture 可以像普通贴图一样使用,通过 GameObject/UI/Raw Image 创建一个图片UI控件,就可以指定 RT 进行显示。

raw image 使用默认的 UI 材质,执行的是 SrcAllpha OneMinusSrcAlpha 混合,因此半透明显示是没问题的,但是 bloom 不是叠加的,而且除非贴图像素和屏幕像素完美匹配(一样大小),否则 bilinear filtering 会使黑色的渲染背景被显示出来,导致半透明边缘出现黑边(如上图)。

因此我们需要自定义 UI shader 来支持其它混合模式。通过拷贝 Default-UI shader,并通过 _SrcBlen 和 _DstBlend shader 属性,添加可配置的混合模式。

cs 复制代码
Shader "Custom RP/UI Custom Blending"
{
    Properties
    {
        ...

        [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
    {
        ...
        Blend [_SrcBlend] [_DstBlend]
        ColorMask [_ColorMask]
        ...

通过链接 Unity's download archive,先选择大版本(2022,2023等),在所有子版本列表中,找到自己版本号的 Unity 版本,点击 Downloads 列的 See all,打开该版本所有相关下载。点击 Other installs,下载 Shaders。下载完成解压,shader 在 DefaultResourcesExtra/UI 目录下

我下载的是 2022.3.62f3 版本的 shader,结果发现其本身就是 One OneMinusSrcAlpha 的,而在我的 unity 内,确实也没发现黑边。不过我还是替换了我们自己的可配置 blend mode 的 shader,最后发现没什么变化

1.7 Post FX Setting Per Camera

下面要支持的特性是,当有多个摄像机时,每个摄像机的后效应该可以分别配置,因此为 CameraSettings 增加一个是否覆盖管线后效配置的开关,以及后效配置数据对象。

cs 复制代码
[Serializable]
public class CameraSettings
{
    ...
    public bool overridePostFX = false;
    public PostFXSettings postFXSettings = default;

    public FinalBlendMode finalBlendMode = new FinalBlendMode{...};
}

在 CameraRenderer.Render 接口中,检查是否覆盖后效配置,如果覆盖,且指定的后效对象有效,则使用指定的后效配置对象进行渲染。

cs 复制代码
public void Render(ScriptableRenderContext context, 
    Camera camera, 
    bool useDynamicBatching, 
    bool useGPUInstancing, 
    bool usePerObjectLights, 
    ShadowSettings shadows,
    PostFXSettings postFXSettings, 
    bool allowHDR,
    int colorLUTResolution
    )
{
    ...
    // 设置后处理相关参数
    var crpCamera = camera.GetComponent<CustomRenderPipelineCamera>();
    CameraSettings cameraSettings = crpCamera ? crpCamera.Settings : defaultCameraSettings;
    if(cameraSettings.overridePostFX && postFXSettings != null)
        postFXSettings = cameraSettings.postFXSettings;
    postFXStack.Setup(context, camera, postFXSettings, useHDR, cameraSettings.finalBlendMode);
    ...
}

2 Rendering Layers

当同时渲染多个摄像机视口时,我们可能希望每个摄像机渲染不同的场景。比如,我们可能会渲染主视口和角色肖像。Unity 同时只支持一个全局的场景(不是有 sub scene 了么?当然跟该主题无关),因此需要限制每个摄像机能看到场景中不同对象。

2.1 Culling Masks

下面只是分析,culling masks 不好用,为引入 rendering layers 做铺垫。

每个游戏对象只能指定属于一个 layer。通过编辑器右上角的 Layers 下拉框,可以指定显示/隐藏哪些 layer。

同样,每个摄像机有 Culling Mask 属性,用来限制该摄像机会渲染哪些 layer,该 mask 是在渲染的 culling 阶段应用的。

每个对象属于一个 layer,culling masks 可以包含多个 layers。例如,有两个摄像机,它们都渲染 Default layer,同时一个摄像机还渲染 Ignore Raycasts layer,另一个还渲染 Water layer。所以一些对象被两个摄像机渲染,还有一些对象仅被其中一个相机渲染。

灯光也有 Culling Masks,当一个对象被某个灯光裁掉,则该对象不会被这个灯光照亮,也不会投射阴影。但是,如果我们为方向光配置 culling masks,会发现仅仅是阴影收到 culling masks 的影响,此外还是会被该光源照亮。

对于其它类型的光源,如果管线的 Use Lights Per Object 选项被关闭,则也会有类似的问题:

但是如果开启了 Use Lights Per Object 选项,则点光和聚光灯的 culling masks 会正常工作,但是方向光依然不行。

导致这些问题的原因是因为,Unity 是在向 GPU 上传每个对象的光源索引时才应用 culling mask,而我们没有使用这些数据,因此 culling 不起作用。而对于方向光,则永远不会起作用,因为我们总是将方向光应用到所有对象。由于阴影渲染时,是在光源位置构建摄像机,用摄像机的裁剪逻辑,因此阴影渲染的裁剪是正确的。

上面的问题在我们的RP中没办法解决,HDRP 也有同样的问题:光源不支持 culling masks。Unity 为 SRP 提供了 Rendering Layers 来解决该问题。使用 rendering layers 有两个好处:首先, renderer 可以不必限制只能在一个 layer,这带来更多灵活性。其次,rendering layers 不可以用在其它类型的对象,比如 default layers 可以用在物理组件上。

在继续 rendering layers 之前,如果光源设置了 culling masks 为 Everything 之外的值,则在光源的 inspector 面板上显示警告信息。光源的 cullingMask 属性,当值为 -1 时表示所有 layers。在 CustomLightEditor 中如果选中的 light 的该属性不是 -1 则显示警告。对于 point 和 spot 光源,当 Use Lights Per Object 选项开启时,效果是正确的,不需要显示警告

cs 复制代码
public class CustomLightEditor : LightEditor
{
    public override void OnInspectorGUI()
    {
        ...
        // 方向光的 culling masks 只能是 Everything
        // 点光源和聚光灯,只有在管线的 UsePerObjectLights 开启时,才允许修改 culling masks
        var light = target as Light;
        if(light.cullingMask != -1)
        {
            string warning = "";
            if(light.type == LightType.Directional)
            {
                warning = "Directional lights must use 'Everything' Culling Mask.";
                EditorGUILayout.HelpBox(warning, MessageType.Warning);
            }
            else if(light.type == LightType.Point || light.type == LightType.Spot)
            {
                var rpAsset = GraphicsSettings.currentRenderPipeline as CustomRenderPipelineAsset;
                if(rpAsset == null || !rpAsset.usePerObjectLights)
                {
                    warning = "Culling Mask for Point and Spot lights requires 'Use Per Object Lights' option enabled in the Render Pipeline Asset.";
                    EditorGUILayout.HelpBox(warning, MessageType.Warning);
                }
            }
        }
    }
}

2.2 Adjusting the Rendering Layer Mask

当使用 SRP 时,Lights 和 MeshRenderer 组件会在 inspector 上显示 Rendering Layer Mask 属性

下拉列表中默认有 32 个 layers,名字分别是 Layer1, Layer2,...。每个 RP 可以自己配置这些名字,通过 RenderPipelineAsset.renderingLayerMaskNames getter 属性返回。因为只有编辑器才会调用该接口,因此将 CustomRenderPipelineAsset 改为 partial 类。然后创建编辑器脚本,返回这个 string[] 属性。通过创建静态构造函数来初始化静态成员,以 "Layer 1" 的形式作为名字。

实践过程中,发现重载 renderingLayerMaskNames 没有效果,重载 prefixedRenderingLayerMaskNames 才有效果,如下代码:

cs 复制代码
public partial class CustomRenderPipelineAsset : RenderPipelineAsset
{
#if UNITY_EDITOR

    //static string[] renderingLayerNames;
    static string[] prefixedRenderingLayerNames;

    static CustomRenderPipelineAsset()
    {
        //renderingLayerNames = new string[32];
        prefixedRenderingLayerNames = new string[32];
        for (int i = 0; i < 32; i++)
        {
            //renderingLayerNames[i] = $"Layer - {i + 1}";
            prefixedRenderingLayerNames[i] = $"Layer {i + 1} ({i})";
        }
    }

    // 没有效果
    //public override string[] renderingLayerMaskNames => renderingLayerNames;

    // 实际上需要重载改属性
    public override string[] prefixedRenderingLayerMaskNames => prefixedRenderingLayerNames;
#endif
}

我们只是改了个名字,对于 MeshRenderer 组件是生效的,但是对于 Light 当我们在下拉框编辑 layers 时,发现无法进行编辑,这个问题现在没有修复的办法,但是我们可以定义一个我们自己版本的属性,这样就可以编辑了。

  • 首先创建 label content

  • 然后实现 DrawRenderingLayerMask 方法,绘制,并实现属性的编辑功能

  • 最后在 OnInspectorGUI 中调用我们的编辑方法

cs 复制代码
public class CustomLightEditor : LightEditor
{
    // 创建 Rendering layer mask label
    static GUIContent renderingLayerMaskLabel =
        new GUIContent("Rendering Layer Mask", "Functional version of above property.");

    public override void OnInspectorGUI()
    {
        // 依然用默认方法绘制 Light 编辑面板
        base.OnInspectorGUI();
        DrawRenderingLayerMask();

        // 判断选中的光源,全都是 spot 类型
        // 选中的 Light 的属性会被序列化缓存,settings 提供了访问缓存属性的接口
        if (!settings.lightType.hasMultipleDifferentValues
            && (LightType)settings.lightType.enumValueIndex == LightType.Spot)
        {
            // 绘制 inner / outer 角编辑控件
            settings.DrawInnerAndOuterSpotAngle();
        }

        // 应用修改后的数据
        settings.ApplyModifiedProperties();
        ...
    }

    void DrawRenderingLayerMask()
    {
        SerializedProperty property = settings.renderingLayerMask;
        EditorGUI.showMixedValue = property.hasMultipleDifferentValues;
        EditorGUI.BeginChangeCheck();
        int mask = property.intValue;
        if (mask == int.MaxValue) 
            mask = -1;
        mask = EditorGUILayout.MaskField(renderingLayerMaskLabel, mask,
            GraphicsSettings.currentRenderPipeline.prefixedRenderingLayerMaskNames);
        if (EditorGUI.EndChangeCheck())
        {
            property.intValue = mask == -1 ? int.MaxValue : mask;
        }
        EditorGUI.showMixedValue = false;
    }
}

尽管我们正确的编辑了光源的 Rendering Layer Mask,但是因为我们还没有应用该 mask ,所以看不到效果。可以通过在 Shadows 中启用 ShadowDrawingSettings. useRenderingLayerMaskTest 来应用。所有的光源都需要处理,包括 RenderDirectionalShaodw, RenderSpotShadows, RenderPointShadows。这样就可以通过 Rendering layer masks 来处理光源和对象的阴影了。

cs 复制代码
void RenderDirectionalShadows(int index, int split, int tileSize)
{
    ShadowedDirectionalLight light = directionalLights[index];
    // 设置阴影渲染参数
    var shadowSettings = new ShadowDrawingSettings(...);
    shadowSettings.useRenderingLayerMaskTest = true;
    ...

2.3 Sending a Mask to the GPU

为了能我们的 Lit.shader 也能处理对象和光源的 Rendering layer mask,就需要将 mask 上传到 GPU。在 UnityInput 中的 UnityPerDraw 结构体中,增加属性来接收 mask

cs 复制代码
CBUFFER_START(UnityPerDraw)
...
real4 unity_WorldTransformParams;
// rendering layer masks
float4 unity_RenderingLayer;
...

在 Surface 结构体中,增加 mask 成员,并在 LitPassFragment 中为其赋值

cs 复制代码
    surface.dither = InterleavedGradientNoise(input.positionCS.xy, 0);
    // asuint 直接以原始数据作为无符号整数,避免先解释成浮点数再转无符号整数
    surface.renderingLayerMask = asuint(unity_RenderingLayer.x);

同时为光源增加成员:

cs 复制代码
struct Light
{
    ...
    uint renderingLayerMask;
};

光源的 rendering layer mask 需要由我们自己上传到 GPU,我们将其存储在光源方向的第4个分量上,并在获取光源数据时赋值

cs 复制代码
CBUFFER_START(_Lights)
// 方向光数量
int _DirLightCount;
float4 _DirLightColors[MAX_DIR_LIGHT_COUNT];
float4 _DirLightDirectionsAndMasks[MAX_DIR_LIGHT_COUNT];
...
float4 _OtherLightDirectionsAndMasks[MAX_OTHER_LIGHT_COUNT];
...
CBUFFER_END
...

Light GetDirectionalLight(int index, Surface surfaceWS, ShadowData shadowData)
{
    ...
    light.renderingLayerMask = asuint(_DirLightDirectionsAndMasks[index].w);
    return light;
}

...

Light GetOtherLight(int index, Surface surfaceWS, ShadowData shadowData)
{
    ...
    light.renderingLayerMask = asuint(_OtherLightDirectionsAndMasks[index].w);
    float rangeAttenuation = Square(saturate(1.0 - Square(distSqr*_OtherLightPositions[index].w)));
    ...
}

在 cpu 端进行赋值。但是要注意,我们不能直接将 masks 赋值给 float,需要通过辅助函数来完成(类似C++的union,C#没有该特性,但是我们可以模拟):

cs 复制代码
public static class ReinterpretExtensions
{
    [StructLayout(LayoutKind.Explicit)]
    struct IntFloat
    {
        [FieldOffset(0)]
        public int intValue;
        [FieldOffset(0)]
        public float floatValue;
    }

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

private void SetupDirectionalLight(int index, int visibleIndex, ref VisibleLight visibleLght, Light light)
{
    dirLightColors[index] = visibleLght.finalColor;
    dirLightDirections[index] = -visibleLght.localToWorldMatrix.GetColumn(2);
    dirLightShadowData[index].w = light.renderingLayerMask.ReinterpretAsFloat();
    dirLightShadowData[index] = shadows.ReserveDirectionalShadows(light, visibleIndex);
}

private void SetupPointLight(int index, int visibleIndex, ref VisibleLight visibleLght, Light light)
{
    ...
    otherLightShadowData[index] = shadows.ReserveOtherShadows(light, visibleIndex);
    otherLightDirections[index] = Vector4.zero;
    otherLightDirections[index].w = light.renderingLayerMask.ReinterpretAsFloat();
}

private void SetupSpotLight(int index, int visibleIndex, ref VisibleLight visibleLght, Light light)
{
    otherLightPositions[index].w = 1.0f / Mathf.Max(visibleLght.range * visibleLght.range, 0.000001f);
    otherLightDirections[index] = -visibleLght.localToWorldMatrix.GetColumn(2);
    otherLightDirections[index].w = light.renderingLayerMask.ReinterpretAsFloat();
    ...
}

在 Lighting.hlsl 中,定义表面和光源 rendering layer mask 是否相交的方法,在计算光照时,先做判断:

cs 复制代码
bool RenderingLayerOverlap(Surface surface, Light light)
{
    return light.renderingLayerMask & surface.renderingLayerMask != 0;
}
...
float3 GetLighting(Surface surfaceWS, BRDF brdf, GI gi)
{
    ...
    for(int i = 0; i < GetDirectionalLightCount(); ++i)
    {
        Light light = GetDirectionalLight(i, surfaceWS, shadowData);
        if (RenderingLayerOverlap(surfaceWS, light))
            color += GetLighting(surfaceWS, brdf, light);
    }
        
#if defined(_LIGHTS_PER_OBJECT)
    // 每个对象定义了影响的光源
    // y 可能大于8,而我们最多支持8个,因此用 min 确保
    for(int i = 0; i < min(8,unity_LightData.y); ++i)
    {
        int index = unity_LightIndices[(uint)i/4][(uint)i%4];
        Light light = GetOtherLight(index, surfaceWS, shadowData);
        if (RenderingLayerOverlap(surfaceWS, light))
            color += GetLighting(surfaceWS, brdf, light);
    }
#else
    // 没有每个对象光源的数据,因此处理所有
    for(int i = 0; i < GetOtherLightCount(); ++i)
    {
        Light light = GetOtherLight(i, surfaceWS, shadowData);
        if (RenderingLayerOverlap(surfaceWS, light))
            color += GetLighting(surfaceWS, brdf, light);
    }
#endif

    return color;
}

最后,因为我们改了 shader 中属性变量名,所以不要忘记在 CPU 同步:

cs 复制代码
int dirLightDirectionID = Shader.PropertyToID("_DirLightDirectionsAndMasks");
...
int otherLightDirectionsID = Shader.PropertyToID("_OtherLightDirectionsAndMasks");

2.4 Camera Rendering Layer Mask

Camera 通过 culling mask 来限制渲染哪些 layers,此外我们还可以利用 Rendering Layer Masks 来限制渲染哪些对象。Camera 没有 Rendering Layer Masks,因此需要向我们定义的 CameraSettings 中增加该属性,用 int 来定义,因为 Light 也是用了 int。默认值为 -1,表示所有 layer。

然后我们需要让 rendering layer mask 以下拉框的形式进行编辑,可以通过实现 custom editor 类来实现,但是我们用更简单的方法:仅为 Rendering Layer Mask 实现下拉框。

首先定义一个属性语义,并用语义修饰属性:

cs 复制代码
public class RenderingLayerMaskFieldAttribute : PropertyAttribute { }

[Serializable]
public class CameraSettings
{
    [RenderingLayerMaskField]
    public int renderingLayerMasks = -1;
    ...
}

然后通过派生 PropertyDrawer 创建 Rendering Layer Mask 的编辑器绘制类

cs 复制代码
[CustomPropertyDrawer(typeof(RenderingLayerMaskFieldAttribute))]
public class RenderingLayerMaskDrawer : PropertyDrawer
{
    public override void OnGUI(Rect position, SerializedProperty property, GUIContent label)
    {
        Draw(position, property, label);
    }

    public static void Draw(Rect position, SerializedProperty property, GUIContent label)
    {
        EditorGUI.showMixedValue = property.hasMultipleDifferentValues;
        EditorGUI.BeginChangeCheck();
        int mask = property.intValue;
        bool isUint = property.type == "uint";
        if (isUint && mask == int.MaxValue)
            mask = -1;
        mask = EditorGUI.MaskField(position, label, mask,
            GraphicsSettings.currentRenderPipeline.prefixedRenderingLayerMaskNames);
        if (EditorGUI.EndChangeCheck())
        {
            property.intValue = isUint && mask == -1 ? int.MaxValue : mask;
        }
        EditorGUI.showMixedValue = false;
    }

    // 没有 rect 的版本,直接从 layout engine 中获取
    public static void Draw(SerializedProperty property, GUIContent label)
    {
        Draw(EditorGUILayout.GetControlRect(), property, label);
    }
}

之前在 CustomLightEditor.cs 中也有绘制 Rendering Layer Masks 的逻辑,改为调用上面的接口:

cs 复制代码
    void DrawRenderingLayerMask()
    {
        SerializedProperty property = settings.renderingLayerMask;
        RenderingLayerMaskDrawer.Draw(property, renderingLayerMaskLabel);
        //EditorGUI.showMixedValue = property.hasMultipleDifferentValues;
        //EditorGUI.BeginChangeCheck();
        //int mask = property.intValue;
        //mask = EditorGUILayout.MaskField(renderingLayerMaskLabel, mask,
        //    GraphicsSettings.currentRenderPipeline.prefixedRenderingLayerMaskNames);
        //if (EditorGUI.EndChangeCheck())
        //{
        //    property.uintValue = (uint)mask;
        //}
        //EditorGUI.showMixedValue = false;
    }

在 CameraRenderer.DrawVisibleGeometry 接口中添加参数传递 mask,并进行应用:

cs 复制代码
void DrawVisibleGeometry(bool useDynamicBatching, bool useGPUInstancing, bool useLightsPerObject,
    int renderingLayerMask)
{
    ...
    var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);
    filteringSettings.renderingLayerMask = (uint)renderingLayerMask;
    context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
    ...
}

现在可以更灵活的用 Rendering Layer Mask 来控制摄像机的渲染。但是需要注意的是裁剪时只会用 culling mask,因此 rendering layer mask 排除掉越多的对象,culling 的执行效率就越高。

2.5 Masking Lights Per Camera

尽管 unity 的管线没有实现,但是为每个摄像机 mask 光源是可能的。我们还是用 CameraSettings.renderingLayerMask 属性,同时加上一个标记,区分是否启用该裁剪:

然后在 Lighting.SetupLights 中接收并应用该参数:

cs 复制代码
...
if ((light.renderingLayerMask & renderingLayerMask) != 0)
{
    //Light 
    switch (visibleLght.lightType)
    {
        ...
    }
}
...

传递合适的参数值:

cs 复制代码
var crpCamera = camera.GetComponent<CustomRenderPipelineCamera>();
CameraSettings cameraSettings = crpCamera ? crpCamera.Settings : defaultCameraSettings;

// 设置光照相关参数
lighting.Setup(context, cullingResults, shadows, usePerObjectLights,
    cameraSettings.maskLights?cameraSettings.renderingLayerMasks:-1);
相关推荐
Hody911 天前
【XR开发系列】Unity下载与安装详细教程(UnityHub、Unity)
unity·游戏引擎·xr
程序员正茂1 天前
在Unity3d中使用Netly开启TCP服务
unity·tcp·netly
Little丶Seven1 天前
使用adb获取安卓模拟器日志
android·unity·adb·个人开发
黄思搏3 天前
Unity坐标转换指南 - 3D与屏幕UI坐标互转
ui·3d·unity
weixin_424294673 天前
在 Unity 游戏开发中,为视频选择 VP8 还是 H.264
unity·游戏引擎
一步一个foot-print4 天前
【Unity】Light Probe 替代点光源给环境动态物体加光照
unity·游戏引擎
@LYZY4 天前
Unity 中隐藏文件规则
unity·游戏引擎·游戏程序·vr
霜绛4 天前
C#知识补充(二)——命名空间、泛型、委托和事件
开发语言·学习·unity·c#
Sator14 天前
使用Unity ASE插件设置数值不会生效的问题
unity·游戏引擎