Multiple Cameras Camera Blending and Rendering Layers

使用不同后期特效设置渲染多个相机

通过自定义混合实现相机层级渲染

支持渲染层级遮罩

按相机遮蔽光源

这是关于创建自定义可编程渲染管线的教程系列第14部分。本次我们将结合后期特效重新探讨多相机渲染。

本教程基于Unity 2019.4.12f1版本创建,并已升级至2022.3.5f1版本。

Looking at the same scene in different ways

从不同视角观察同一场景

1. Combining Cameras 相机组合

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

++关于第一人称游戏中角色手部和武器的显示?
第一人称游戏中角色持有的物品通常会以不同于场景其他部分的视场角呈现(原因多样)。这可以通过第二台相机实现,但也可以通过调整视图矩阵仍使用同一台相机来渲染
++

1.1 Split Screen 分屏显示

让我们从分屏场景开始分析:两个并排的相机组成,左侧相机视口矩形宽度设为0.5,右侧相机宽度也为0.5且X位置设为0.5。若不使用后期特效,该配置能按预期工作。

Split screen without post FX, showing two different views of the same scene

未启用后期特效的分屏画面,显示同一场景的两个不同视角

但如果启用后期特效,渲染就会出错。两个相机虽然以正确尺寸渲染,但最终都会覆盖整个相机目标缓冲区,导致只有最后一个相机的渲染结果可见

Split screen with post FX, incorrect

启用后期特效的分屏渲染(错误效果)

出现此问题的原因是调用SetRenderTarget时会同时将视口重置为覆盖整个目标。为了在最终后期特效通道中应用视口设置,我们需在设置渲染目标后、绘制操作前设置视口。具体实现方式:复制PostFXStack.Draw方法并重命名为DrawFinal,在SetRenderTarget后立即调用buffer.SetViewport,传入相机的pixelRect作为参数。由于这是最终绘制,除source参数外其他参数均可替换为硬编码值

cs 复制代码
	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方法

cs 复制代码
	void DoColorGradingAndToneMapping (int sourceId) {
		...
		Draw(...)
		DrawFinal(sourceId);
		buffer.ReleaseTemporaryRT(colorGradingLUTId);
	}

Split screen with post FX, correct

启用后期特效的分屏渲染(正确效果)

如果您使用基于图块的GPU,可能会在渲染视口边界周围出现超出其范围的渲染伪影。这是因为被遮罩的图块区域包含垃圾数据所致。我们可以通过在非完整视口时加载目标缓冲区来解决此问题。这个问题并非Unity 2022特有,但我注意到该问题是因为Apple Silicon Mac采用了基于图块的GPU并支持"不关心"选项,而我编写本系列教程时这些设备尚未问世

cs 复制代码
	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
		);
		...
	}
1.2 Layering Cameras 相机分层渲染

除了渲染到不同区域,我们还可以让相机视口重叠。最简单的示例是:使用覆盖整个屏幕的常规主相机,然后添加第二个相机(以相同视角但较小视口稍后渲染)。我将第二个视口尺寸减半并通过设置XY位置为0.25使其居中

Two camera layers

双相机层级渲染

若不使用后期特效,通过将顶部相机层级设置为仅清除深度,可将其转换为半透明叠加层(这会移除其天空盒并显露下层内容)。但启用后期特效后此方法失效------因为此时强制使用CameraClearFlags.Color,我们将看到相机的背景色(默认为深蓝色)

Second camera set to clear depth, without and with post FX

第二台相机设置为仅清除深度(未启用与启用后期特效对比)

为了让层级透明度与后期特效兼容,我们可以修改PostFXStack着色器的最终通道,使其执行Alpha混合(而非默认的One Zero混合模式)

cs 复制代码
		Pass {
			Name "Final"

			Blend SrcAlpha OneMinusSrcAlpha
			
			HLSLPROGRAM
				#pragma target 3.5
				#pragma vertex DefaultPassVertex
				#pragma fragment FinalPassFragment
			ENDHLSL
		}

这要求我们在FinalDraw中必须始终加载目标缓冲区

cs 复制代码
	void DrawFinal (RenderTargetIdentifier from) {
		buffer.SetGlobalTexture(fxSourceId, from);
		buffer.SetRenderTarget(
			BuiltinRenderTextureType.CameraTarget,
			RenderBufferLoadAction.Load, RenderBufferStoreAction.Store
		);
		...
	}

现在将叠加相机的背景颜色Alpha值设为零。只要禁用泛光效果,这似乎就能正常工作。我添加了两个高亮自发光物体来明确显示泛光是否处于激活状态

Bloom disabled and enabled

泛光效果禁用与启用对比

当前泛光效果无法正常工作的原因是该效果未能保留透明度。我们可以通过调整最终泛光通道来修复此问题,使其保持来自高分辨率源纹理的原始透明度。需要同时调整BloomAddPassFragment和BloomScatterFinalPassFragment,因为这两个函数都可能用于最终绘制

cs 复制代码
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);
}

Layered with transparency and bloom

分层渲染(含透明度与泛光效果)

透明度现在能与泛光效果协同工作,但泛光对透明区域的贡献变得不可见。我们可以通过将最终通道切换为预乘Alpha混合来保留泛光效果。这需要将相机的背景颜色设置为纯透明黑色,因为它会叠加到下层画面上

cs 复制代码
			Name "Final"

			Blend One OneMinusSrcAlpha

Bloom affects transparent areas

泛光效果影响透明区域

1.3 Layered Alpha 分层Alpha混合

我们当前的分层方法仅在着色器生成适用于相机图层混合的合理Alpha值时有效。之前我们并不关心写入的Alpha值,因为它们从未被使用。但现在当两个Alpha值为0.5的物体渲染到同一纹素时,该纹素的最终Alpha值应为0.25;当任一Alpha值为1时,结果应始终为1;当第二个Alpha值为零时,应保留原始Alpha值。所有这些情况都可以通过使用One OneMinusSrcAlpha混合Alpha通道来覆盖。我们可以通过在颜色混合模式后添加逗号和Alpha混合模式,来单独配置着色器的Alpha通道混合模式。请为Lit和Unlit着色器的常规通道进行此设置

cs 复制代码
			Blend [_SrcBlend] [_DstBlend], One OneMinusSrcAlpha

只要使用合适的Alpha值(通常意味着写入深度的物体应始终生成Alpha值为1),这种方法就能正常工作。这对于不透明材质似乎很直接,但如果它们使用的基底贴图包含变化的Alpha值就会出错。裁剪材质也可能出错,因为它们依赖Alpha阈值来丢弃片段------被裁剪的片段没问题,但未被裁剪的片段其Alpha值应变为1

Opaque cubes with alpha zero add to the base layer instead of replacing it

Alpha值为零的不透明立方体与底层叠加而非替换底层

确保着色器中Alpha行为正确的最快捷方法是在LitInput和UnlitInput中都向UnityPerMaterial缓冲区添加_ZWrite属性

cs 复制代码
	UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
	UNITY_DEFINE_INSTANCED_PROP(float, _ZWrite)

然后在两个输入文件中添加带alpha参数的GetFinalAlpha函数:当_ZWrite设为1时返回1,否则返回传入的alpha值

cs 复制代码
float GetFinalAlpha (float alpha) {
	return INPUT_PROP(_ZWrite) ? 1.0 : alpha;
}

在LitPassFragment中通过此函数过滤表面alpha值,以在最终阶段获得正确的alpha值

cs 复制代码
float4 LitPassFragment (Varyings input) : SV_TARGET {
	...
	return float4(color, GetFinalAlpha(surface.alpha));
}

同时在UnlitPassFragment中对基础alpha值执行相同处理

cs 复制代码
float4 UnlitPassFragment (Varyings input) : SV_TARGET {
	...
	return float4(base.rgb, GetFinalAlpha(base.a));
}
1.4 Custom Blending 自定义混合模式

与前一相机图层混合仅对叠加相机有意义。底层相机会与相机目标的初始内容(可能是随机数据或之前帧的累积结果,除非编辑器提供了已清除的目标)进行混合。因此首个相机应使用One Zero混合模式。为支持替换、叠加及更特殊的图层选项,我们将为相机添加可配置的最终混合模式(在启用后期特效时使用)。为此创建一个新的可序列化CameraSettings配置类(类似阴影设置),将源和目标混合模式封装在内部的FinalBlendMode结构体中以便管理,默认设为One Zero混合模式

cs 复制代码
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组件。该组件只能被添加到游戏对象一次(且该对象必须是相机)。为其添加包含getter属性的CameraSettings配置字段。由于设置项是类对象,该属性必须确保实例存在------若组件尚未被编辑器序列化或在运行时添加到相机,则需创建新的设置对象实例

cs 复制代码
using UnityEngine;

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

	[SerializeField]
	CameraSettings settings = default;

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



++?? 的作用是什么?
这是空值合并运算符,它是以下写法的简写形式:
++

cs 复制代码
	public CameraSettings Settings =>
		settings == null ? settings = new CameraSettings() : settings;

该属性更详细的实现形式可以是:

cs 复制代码
	public CameraSettings Settings {
		get {
			if (settings == null) {
				settings = new CameraSettings();
			}
			return settings;
		}
	}



现在我们可以在CameraRenderer.Render开始时获取相机的CustomRenderPipelineCamera组件。为支持无自定义设置的相机,我们会检查该组件是否存在:若存在则使用其设置,否则使用默认设置对象(该对象只需创建一次并存储在静态字段中)。随后在设置后期特效堆栈时传递最终混合模式参数

cs 复制代码
	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现在需要持续追踪相机的最终混合模式

cs 复制代码
	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浮点数着色器属性。同时,如果目标混合模式不为零,我们现在还需要始终加载目标缓冲区

cs 复制代码
	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
		);
		...
	}

最后,在最终通道中使用新属性替代硬编码的混合模式

cs 复制代码
			Name "Final"

			Blend [_FinalSrcBlend] [_FinalDstBlend]

从现在开始,未配置自定义设置的相机将使用默认的One Zero最终混合模式覆盖目标缓冲区内容。叠加相机需配置不同的最终混合模式(通常设为One OneMinusSrcAlpha)

Component with settings for overlay camera

用于叠加相机的配置组件

1.5 Render Textures 渲染纹理

除了创建分屏显示或直接叠加相机,另一种常见做法是将相机用于游戏内显示屏或作为GUI组成部分。这些情况下相机目标必须是渲染纹理(可以是资源文件或运行时创建)。例如我通过Assets/Create/Render Texture创建了200×100的渲染纹理,未分配深度缓冲区------因为启用后期特效的相机会创建自带深度缓冲的中间渲染纹理

Render texture asset

渲染纹理资源

随后我创建了一个相机,通过将其连接到相机的Target Texture属性,使该相机将场景渲染到此纹理

Camera target texture set

相机目标纹理已设置

与常规渲染相同,底层相机仍需使用One Zero作为最终混合模式。编辑器初始会显示纯黑色纹理,但之后渲染纹理将保留最后一次渲染的内容。多个相机可以像常规操作一样,以任意视口渲染到同一渲染纹理。唯一区别在于:Unity会自动先渲染带渲染纹理目标的相机(按深度值递增顺序),再渲染无目标的相机

1.6 Unity UI

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

UI raw image, partially overlapping a button

UI原始图像,部分重叠于按钮上方

原始图像使用默认UI材质(执行标准的SrcAlpha OneMinusSrcAlpha混合)。因此透明度可以正常工作,但泛光效果不具备叠加性,且除非纹理以像素完美方式显示,否则双线性滤波会使相机的黑色背景在透明边缘显示为暗色轮廓。

为支持其他混合模式,我们需要创建自定义UI着色器。只需复制Default-UI着色器,通过_SrcBlend和_DstBlend着色器属性添加可配置混合功能。我还调整了着色器代码以更符合本教程系列的风格

cs 复制代码
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 { ... }
	}
}

这是该通道的代码,除代码风格外未作其他修改

cs 复制代码
		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
		}

Raw UI image using custom UI shader with premultiplied alpha blending

使用预乘Alpha混合的自定义UI着色器的原始UI图像

++哪里可以找到默认UI着色器的源代码?
请访问Unity的下载存档,找到所需Unity版本,然后从下拉菜单中选择"内置着色器"。该着色器位于DefaultResourcesExtra/UI文件夹中
++

1.7 Post FX Settings Per Camera 每相机后期特效设置

当使用多相机时,应当支持为每个相机配置不同的后期特效。为此在CameraSettings中添加切换选项来控制是否覆盖全局后期特效设置,同时添加专属的PostFXSettings字段

cs 复制代码
	public bool overridePostFX = false;

	public PostFXSettings postFXSettings = default;

Camera post FX override settings

相机后期特效覆盖设置

让CameraRenderer.Render检查相机是否覆盖了后期特效设置。如果是,则使用相机的设置替换渲染管线提供的设置

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

		if (cameraSettings.overridePostFX) {
			postFXSettings = cameraSettings.postFXSettings;
		}

现在每个相机都可以使用默认或自定义的后期特效。例如,我将底层相机设为默认配置,关闭了叠加相机的后期特效,并为渲染纹理相机配置了不同的后期特效------包含冷色温偏移和中性色调映射

Different post FX settings per camera

每相机独立后期特效设置

2. Rendering Layers 渲染层级

当同时显示多个相机视图时,我们并不总是希望所有相机都渲染相同的场景。例如,我们可能需要同时渲染主视角和角色肖像。Unity一次仅支持单个全局场景,因此我们必须采用某种方式来限制每个相机可见的内容

2.1 Culling Masks 剔除遮罩

每个游戏对象都属于唯一的层级。场景窗口可通过编辑器右上角的Layers下拉菜单筛选显示的层级。同样,每个相机都有Culling Mask属性,能以相同方式限制其显示内容------该遮罩会在渲染的剔除阶段生效。

每个对象仅属于一个层级,而剔除遮罩可包含多个层级。例如:两个相机都可渲染Default层,但其中一个额外渲染Ignore Raycasts层,另一个则额外渲染Water层。这样某些对象会同时出现在两个相机中,其他对象仅对其中一个相机可见,还有些对象可能完全不被渲染

Split screen with different culling masks per camera

分屏显示:每个相机使用不同的剔除遮罩

++为什么更改对象的层级没有效果?
本应生效,但存在一个缺陷:层级更改的撤销/重做操作可能不会影响对象的渲染状态。切换播放模式或显式重新更改层级应能修复此问题
++

光源同样具有剔除遮罩。其原理是:被光源剔除的对象会表现得如同该光源不存在------对象既不受该光源照射,也不会为其投射阴影。但如果我们用定向光测试,会发现只有其阴影效果会受到影响

ulling mask applied to directional light only affects shadows

应用于定向光的剔除遮罩仅影响阴影效果

如果我们用其他光源类型测试也会出现同样情况(当渲染管线的"使用逐对象光源"选项被禁用时)

Same culling mask applied to bright point light

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

如果启用"使用逐对象光源",则光源剔除会按预期工作,但仅适用于点光源和聚光灯

Point light with lights-per-object enabled

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

出现这些结果是因为Unity在向GPU发送逐对象光源索引时应用了光源的剔除遮罩。因此,如果我们不使用这些索引,剔除功能就不会生效。而定向光始终无法实现剔除,因为我们总是将其应用于所有对象。阴影始终能被正确剔除,因为在从光源视角渲染阴影投射物时,光源的剔除遮罩会像相机遮罩一样被使用。

我们目前的方法无法完全支持光源的剔除遮罩。这个限制并非致命问题,HDRP也不支持光源的剔除遮罩。Unity为SRP提供了渲染层级作为替代方案。使用渲染层级而非游戏对象层级有两个好处:首先,渲染器不限于单个层级,灵活性大大提升;其次,渲染层级不用于其他用途(而默认层级还用于物理系统)。

在继续讨论渲染层级之前,让我们在光源检查器中当其剔除遮罩设置为非"Everything"时显示警告。光源的剔除遮罩可通过其cullingMask整数属性获取,-1表示所有层级。如果CustomLightEditor的目标遮罩设置为其他值,请在OnInspectorGUI末尾调用EditorGUILayout.HelpBox,使用说明"剔除遮罩仅影响阴影"的字符串和MessageType.Warning来显示警告图标

cs 复制代码
	public override void OnInspectorGUI() {
		...

		var light = target as Light;
		if (light.cullingMask != -1) {
			EditorGUILayout.HelpBox(
				"Culling Mask only affects shadows.",
				MessageType.Warning
			);
		}
	}

Culling mask warning for lights

光源剔除遮罩警告

我们可以更具体地说明:启用"使用逐对象光源"设置会对非定向光源产生不同的影响

cs 复制代码
			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
			);
2.2 Adjusting the Rendering Layer Mask 调整渲染层级遮罩

当使用SRP时,光源和MeshRenderer组件的检查器会显示"渲染层级遮罩"属性(该属性在使用默认RP时会被隐藏)

Rendering layer mask for MeshRenderer

MeshRenderer的渲染层级遮罩

默认情况下,下拉菜单显示32个名为Layer1、Layer2等的层级。这些层级的名称可按渲染管线配置,通过重写RenderPipelineAsset.renderingLayerMaskNames的getter属性实现。由于这仅影响下拉菜单的显示,我们只需为Unity编辑器实现此功能。因此将CustomRenderPipelineAsset转换为分部类

cs 复制代码
public partial class CustomRenderPipelineAsset : RenderPipelineAsset { ... }

然后为其创建一个仅限编辑器使用的脚本资源,重写该属性。它返回一个字符串数组,我们可以在静态构造函数中创建该数组。我们将从与默认名称相同的名称开始,只是在"Layer"单词和数字之间添加空格

cs 复制代码
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,使用相同标签并添加提示信息,说明这是上方属性的可用版本

cs 复制代码
	static GUIContent renderingLayerMaskLabel =
		new GUIContent("Rendering Layer Mask", "Functional version of above property.");

接下来创建一个DrawRenderingLayerMask方法,作为LightEditor.DrawRenderingLayerMask的替代方案,该方法能将修改后的值重新赋值给属性。为了让下拉菜单使用RP的层级名称,我们不能简单地依赖EditorGUILayout.PropertyField。我们需要从设置中获取相关属性,确保处理多选混合值,将遮罩作为整型值获取并显示,最后将变更后的值重新赋值给属性------这正是默认光源检查器版本缺失的关键步骤。

通过调用EditorGUILayout.MaskField并传入标签、遮罩值和GraphicsSettings.currentRenderPipeline.renderingLayerMaskNames作为参数来显示下拉菜单

cs 复制代码
	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后直接调用新方法,这样额外的"渲染层级遮罩"属性就会直接显示在无效属性下方。同时,我们现在必须始终调用ApplyModifiedProperties以确保对渲染层级遮罩的更改能应用到光源上

cs 复制代码
	public override void OnInspectorGUI() {
		base.OnInspectorGUI();
		DrawRenderingLayerMask();
		
		if (
			!settings.lightType.hasMultipleDifferentValues &&
			(LightType)settings.lightType.enumValueIndex == LightType.Spot
		)
		{
			settings.DrawInnerAndOuterSpotAngle();
			//settings.ApplyModifiedProperties();
		}

		settings.ApplyModifiedProperties();

		...
	}

Extra rendering layer mask property for light

光源的额外渲染层级遮罩属性

我们自定义的属性确实能应用更改,但选择"Everything"或"Layer 32"选项时会产生与选择"Nothing"相同的结果。这是因为光源的渲染层级遮罩内部存储为无符号整数(uint)------这很合理,因为它用作位掩码,但SerializedProperty仅支持获取和设置有符号整数值。

"Everything"选项由-1表示,但属性会将其钳制为零。而"Layer 32"对应最高位(代表一个大于int.MaxValue的数值),该属性同样会将其替换为零。

我们可以通过将渲染层级名称数量减少到31个(移除最后一个层级)来解决第二个问题。这仍然提供了充足的层级数量(HDRP仅支持8个层级)

cs 复制代码
	renderingLayerNames = new string[31];

通过移除一个层级后,"Everything"选项现在由除最高位外所有位都设置的值表示,该值与int.MaxValue匹配。因此我们可以通过显示-1同时存储int.MaxValue来解决第一个问题。默认属性未实现此逻辑,这就是为什么在适当时机它会显示"Mixed..."而非"Everything"------HDRP同样存在此问题

cs 复制代码
		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;
		}

Functional rendering layer mask property

功能完整的渲染层级遮罩属性

我们终于可以正确调整光源的渲染层级遮罩属性了。但由于该遮罩默认未被使用,因此尚未产生实际变化。我们可以通过在Shadows中启用ShadowDrawingSettings的useRenderingLayerMaskTest来将其应用于阴影渲染------在RenderDirectionalShadows、RenderSpotShadows和RenderPointShadows中为所有光源启用此设置。现在我们可以通过配置物体和光源的渲染层级遮罩来选择性消除阴影了

cs 复制代码
		var shadowSettings = new ShadowDrawingSettings(
			...
		) {
			useRenderingLayerMaskTest = true
		};
2.3 Sending a Mask to the GPU 向GPU发送遮罩数据

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

cs 复制代码
	real4 unity_WorldTransformParams;

	float4 unity_RenderingLayer;

我们将把遮罩作为位掩码添加到Surface结构体中,使用uint类型存储

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

在LitPassFragment中设置表面遮罩时,必须使用asuint内置函数。这样可以获取原始数据,而不会执行从float到uint的数值类型转换(这种转换会改变位模式)

cs 复制代码
	surface.dither = InterleavedGradientNoise(input.positionCS.xy, 0);
	surface.renderingLayerMask = asuint(unity_RenderingLayer.x);

我们同样需要对Light结构体进行相同处理,为其添加一个uint类型的渲染层级遮罩字段

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

由我们负责将遮罩发送到GPU。为此,我们将其存储在_DirectionalLightDirections和_OtherLightDirections数组未使用的第四个分量中。为清晰起见,请在其名称后添加AndMasks后缀

cs 复制代码
CBUFFER_START(_CustomLight)
	...
	float4 _DirectionalLightDirectionsAndMasks[MAX_DIRECTIONAL_LIGHT_COUNT];
	...
	float4 _OtherLightDirectionsAndMasks[MAX_OTHER_LIGHT_COUNT];
	...
CBUFFER_END

在GetDirectionalLight中复制遮罩数据

cs 复制代码
	light.direction = _DirectionalLightDirectionsAndMasks[index].xyz;
	light.renderingLayerMask = asuint(_DirectionalLightDirectionsAndMasks[index].w);

在GetOtherLight中同样复制遮罩数据

cs 复制代码
	float3 spotDirection = _OtherLightDirectionsAndMasks[index].xyz;
	light.renderingLayerMask = asuint(_OtherLightDirectionsAndMasks[index].w);

在CPU端,调整Lighting类中的标识符和数组名称以保持匹配。同时复制光源的渲染层级遮罩数据:从SetupDirectionalLight开始(该方法现在需要直接访问Light对象),我们将其添加为参数

cs 复制代码
	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参数以保持一致性

cs 复制代码
	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)。由于不使用方向向量,可将其设为零

cs 复制代码
	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对象,并将其传递给所有设置方法。稍后我们还将在该方法中对光源进行其他操作

cs 复制代码
			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函数,该函数通过检查表面和光源的位掩码按位与结果是否非零来判断两者的遮罩是否重叠

cs 复制代码
bool RenderingLayersOverlap (Surface surface, Light light) {
	return (surface.renderingLayerMask & light.renderingLayerMask) != 0;
}

++着色器支持位运算吗?
是的,除非你的目标是OpenGL ES 2.0(但我们并不支持该平台)
++

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

cs 复制代码
	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

我们不能把检查放在另一个GetLighting函数里吗?

确实可以,这样代码量会稍少一些。但着色器编译器在这种情况下不会生成分支指令------即使不需要,光照计算仍会始终执行然后被丢弃。虽然可以用UNITY_BRANCH强制分支,但若在跳过光源时返回零值,仍会产生无意义的加法操作。虽然也有其他解决方案,但此时代码会变得复杂而收益甚微

2.4 Reinterpreting an Int as a Float 将整型重新解释为浮点型

虽然此时渲染遮罩已能影响光照,但效果并不正确。Light.renderingLayerMask属性将其位掩码以int类型暴露,在光源设置方法中转换为float时会发生数据错乱。由于无法直接向GPU发送整型数组,我们需要在不进行类型转换的情况下将int重新解释为float,但C#中没有与HLSL的asuint直接等效的方法。

由于C#是强类型语言,无法像HLSL那样简单地进行数据重新解释。我们可以通过使用联合结构体来实现类型别名。为此我们为int添加一个ReinterpretAsFloat扩展方法以隐藏实现细节。创建一个静态的ReinterpretExtensions类来包含此方法,初始版本仅执行常规类型转换

cs 复制代码
public static class ReinterpretExtensions {

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

在三个光源设置方法中使用ReinterpretAsFloat来替代隐式类型转换

cs 复制代码
	dirAndMask.w = light.renderingLayerMask.ReinterpretAsFloat();

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

cs 复制代码
	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命名空间

cs 复制代码
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字段表示相同数据,仅解释方式不同。这保持了位掩码的完整性,渲染层级遮罩现在能正确工作了

Directional light ignores half the objects

定向光源忽略了一半的物体

为什么不使用不安全代码?

虽然可行,但不安全代码需要为项目显式启用,这会增加代码共享的难度。而且项目团队可能根本不允许使用不安全代码。联合结构体方法可以避免这些问题

2.5 Camera Rendering Layer Mask 相机渲染层级遮罩

除了使用现有的剔除遮罩外,我们还可以利用渲染层级遮罩来限制相机的渲染内容。Camera组件本身没有渲染层级遮罩属性,但我们可以将其添加到CameraSettings中。我们将其设为int类型(因为光源的遮罩也以int形式暴露),默认值设为-1表示所有层级

cs 复制代码
public int renderingLayerMask = -1;

Camera rendering layer mask, exposed as integer

相机渲染层级遮罩,以整数形式暴露

要将遮罩以下拉菜单形式暴露,我们需要为其创建自定义GUI。但与其为整个CameraSettings类创建自定义编辑器,不如仅针对渲染层级遮罩制作一个。

首先,为表明字段代表渲染层级遮罩,创建继承自PropertyAttribute的RenderingLayerMaskFieldAttribute类。这只是一个标记属性,无需其他功能。注意这不是编辑器类型,因此不应放在Editor文件夹中

cs 复制代码
using UnityEngine;

public class RenderingLayerMaskFieldAttribute : PropertyAttribute {}

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

cs 复制代码
	[RenderingLayerMaskField]
	public int renderingLayerMask = -1;

现在创建一个继承自PropertyDrawer的自定义属性绘制器编辑器类,并为其添加针对我们属性类型的CustomPropertyDrawer特性。将CustomLightEditor.DrawRenderingLayerMask复制到该类中,重命名为Draw,并设为public static。然后为其添加三个参数:位置Rect、序列化属性和GUIContent标签。使用这些参数来调用EditorGUI.MaskField(替代原先的EditorGUILayout.MaskField)

cs 复制代码
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时(即其type属性等于"uint"时),我们才需要单独处理-1的情况

cs 复制代码
		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方法

cs 复制代码
	public override void OnGUI (
		Rect position, SerializedProperty property, GUIContent label
	) {
		Draw(position, property, label);
	}

Rendering layer mask dropdown menu

渲染层级遮罩下拉菜单

为了让Draw更易用,添加一个不带Rect参数的版本。通过调用EditorGUILayout.GetControlRect从布局引擎获取单行位置矩形

cs 复制代码
	public static void Draw (SerializedProperty property, GUIContent label) {
		Draw(EditorGUILayout.GetControlRect(), property, label);
	}

现在我们可以从CustomLightEditor中移除DrawRenderingLayerMask方法,改为调用RenderingLayerMaskDrawer.Draw

cs 复制代码
	public override void OnInspectorGUI() {
		base.OnInspectorGUI();
		//DrawRenderingLayerMask();
		RenderingLayerMaskDrawer.Draw(
			settings.renderingLayerMask, renderingLayerMaskLabel
		);
		
		...
	}

	//void DrawRenderingLayerMask () { ... }

要应用相机的渲染层级遮罩,请向CameraRenderer.DrawVisibleGeometry添加参数,并将其作为名为renderingLayerMask的参数传递给FilteringSettings构造函数(需转换为uint类型)

cs 复制代码
	void DrawVisibleGeometry (
		bool useDynamicBatching, bool useGPUInstancing, bool useLightsPerObject,
		int renderingLayerMask
	) {
		...

		var filteringSettings = new FilteringSettings(
			RenderQueueRange.opaque, renderingLayerMask: (uint)renderingLayerMask
		);

		...
	}

然后在Render中调用DrawVisibleGeometry时一并传递渲染层级遮罩参数

cs 复制代码
		DrawVisibleGeometry(
			useDynamicBatching, useGPUInstancing, useLightsPerObject,
			cameraSettings.renderingLayerMask
		);

现在可以使用更灵活的渲染层级遮罩来控制相机的渲染内容。例如,我们可以让某些物体即使不被相机看见也能投射阴影,而无需创建专门的阴影专用物体

Only rendering objects not affected by the light, plus the ground

仅渲染不受光源影响的物体及地面

需要记住的是,只有剔除遮罩会用于剔除计算,因此如果排除了大量物体,使用常规剔除遮罩会有更好的性能表现

2.6 Masking Lights Per Camera 按相机遮蔽光源

虽然Unity的渲染管线不提供此功能,但除了几何体外,我们还可以按相机遮蔽光源。我们将再次使用渲染层级来实现此功能,但由于这是非标准行为,我们通过在CameraSettings中添加切换选项来使其成为可选项。

cs 复制代码
	public bool maskLights = false;

Camera set to mask lights

相机设置为遮蔽光源模式

要实现此功能,我们只需在Lighting.SetupLights中跳过被遮蔽的光源。为此给该方法添加一个渲染层级遮罩参数,然后检查每个光源的渲染层级遮罩是否与提供的遮罩重叠。如果重叠则继续执行switch语句来设置光源,否则跳过该光源

cs 复制代码
	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必须同时传递渲染层级遮罩参数

cs 复制代码
	public void Setup (
		ScriptableRenderContext context, CullingResults cullingResults,
		ShadowSettings shadowSettings, bool useLightsPerObject, int renderingLayerMask
	) {
		...
		SetupLights(useLightsPerObject, renderingLayerMask);
		...
	}

并且我们需要在CameraRenderer.Render中提供相机的遮罩,但仅当它适用于光源时才使用,否则使用-1

cs 复制代码
		lighting.Setup(
			context, cullingResults, shadowSettings, useLightsPerObject,
			cameraSettings.maskLights ? cameraSettings.renderingLayerMask : -1
		);

现在我们可以实现诸如让两个相机渲染同一场景但采用不同光照的效果,无需在两次渲染之间调整光源设置。这也使得在世界原点渲染独立场景(如角色肖像)时,可以轻松避免主场景光照对其产生影响。需要注意的是,此功能仅适用于实时光照,完全烘焙的光照和混合光源的烘焙间接光照贡献无法被遮蔽

Two cameras seeing the same scene in a different light

两个相机在不同光照下观察同一场景

++如何为该场景配置遮罩?
所有可见物体的渲染层级遮罩都设置为"全部"。定向光的遮罩设置为单个层级,点光源的遮罩设置为另一个不同的层级。左侧相机的遮罩设置为排除点光源层级的全部内容,右侧相机的遮罩设置为排除定向光层级的全部内容。最终结果是每个相机只能看到两个光源中的一个
++

相关推荐
郝学胜-神的一滴1 天前
计算机图形学中的光照模型:从基础到现代技术
开发语言·c++·程序人生·图形渲染
玖釉-5 天前
三维模型数据结构与存储方式解析
数据结构·算法·图形渲染
玖釉-8 天前
基于屏幕空间投影面积的剔除(Screen-space Area Culling, SSAC)
图形渲染
头发掉光的程序员12 天前
第九章 纹理贴图
c++·图形渲染·direct12
jiuzhouhi12 天前
LOD and Reflections Adding Details
图形渲染
元让_vincent13 天前
论文Review SLAM R3LIVE | ICRA2022 港大MARS | 可以生成Mesh的激光视觉惯性SLAM
3d·机器人·图形渲染·slam·建图
jiuzhouhi14 天前
Directional Shadows Cascaded Shadow Maps
图形渲染
小于小于大橙子20 天前
3D Gaussian Splatting理论详解
计算机视觉·3d·图形渲染
我想_iwant20 天前
PBR 渲染基础与渲染方程
图形渲染