为什么选择unity默认管线 unity自定义管线_ci


本文是自定义可渲染管线系列比较重要的章节,我们将实现自定义可编程渲染管线对灯光照明的支持。


为什么选择unity默认管线 unity自定义管线_数据_02


如果我们想创建一个更加逼真的场景,那么我们必须要模拟物体表面的光照现象。这需要提供更加复杂的shader才能实现。

LitShader

复制UnlitPass.hlsl文件并将其重命名为LitPass,然后修改引用保护以及顶点、片元函数的名字。我们将在后面添加灯光计算。


#ifndef CUSTOM_LIT_PASS_INCLUDED
#define CUSTOM_LIT_PASS_INCLUDED

…

Varyings LitPassVertex (Attributes input) { … }

float4 LitPassFragment (Varyings input) : SV_TARGET { … }

#endif


同时复制Unlit shader文件,并重命名为Lit。修改它的菜单名,引用文件和所使用的函数。让我们也将默认颜色更改为灰色,因为在光线充足的场景中全白的表面可能显得非常明亮。 默认情况下,unity的通用管道也使用灰色。


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


我们将使用一种自定义的照明方法,通过将着色器的照明模式设置为CustomLit。在Pass模块中添加一个Tag标签,其中包括”LightMode”=”CustomLit”。


Pass {
	Tags {
		"LightMode" = "CustomLit"
	}

	…
}


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


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


然后将其添加到要在DrawVisibleGeometry中渲染的过程中,就像在DrawUnsupportedShaders中所做的那样。


var drawingSettings = new DrawingSettings(
			unlitShaderTagId, sortingSettings
		) {
			enableDynamicBatching = useDynamicBatching,
			enableInstancing = useGPUInstancing
		};
		drawingSettings.SetShaderPassName(1, litShaderTagId);


现在我们可以创建一个新的不透明材质了,虽然现在它的结果跟无光照材质一样。

法向量

一个物体的光照结果取决于很多因素,比如物体表面与光线的相对角度。要想知道物体表面的方向,我们必须要知道物体表面的法向,它是一个单位长度的指向表面正向的向量。这个向量是顶点数据的一部分,在物体局部空间中定义,就像位置坐标一样。所以将它添加到LitPass中的属性中。


struct Attributes {
	float3 positionOS : POSITION;
	float3 normalOS : NORMAL;
	float2 baseUV : TEXCOORD0;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};


光照是根据每个片段计算的,所以我们必须将法向量也添加到Varyings中。我们将在世界空间中执行计算,因此将其命名为normalWS。


struct Varyings {
	float4 positionCS : SV_POSITION;
	float3 normalWS : VAR_NORMAL;
	float2 baseUV : VAR_BASE_UV;
	UNITY_VERTEX_INPUT_INSTANCE_ID
};


我们可以使用SpaceTransforms库中的TransformObjectToWorldNormal把法向量从局部坐标转换到世界空间。


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


为了验证我们是否在LitPassFragment中得到了一个正确的法向量,我们可以使用它作为一种颜色。


base.rgb = input.normalWS;
	return base;


负值是无法显示的,所以它们被限制为0。


为什么选择unity默认管线 unity自定义管线_unity 显示太阳_03


法线插值

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


base.rgb = abs(length(input.normalWS) - 1.0) * 10.0;


为什么选择unity默认管线 unity自定义管线_ci_04


我们可以通过对LitPassFragment中的法向量进行归一化来平滑插值失真。当只观察法向量时,这种差异并不是很明显,但当用于照明时,这种差异就更明显了。


base.rgb = normalize(input.normalWS);


为什么选择unity默认管线 unity自定义管线_数据_05


表面参数

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


#ifndef CUSTOM_SURFACE_INCLUDED
#define CUSTOM_SURFACE_INCLUDED

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

#endif


然后在LitPass中,我们在Common之后将Surface.hlsl文件引入进来。这样我们可以让LitPass保持简短。


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


在LitPassFragment中定义一个Surface变量并填充它。最后的结果就是表面的颜色和alpha值。


Surface surface;
	surface.normal = normalize(input.normalWS);
	surface.color = base.rgb;
	surface.alpha = base.a;

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


光照计算

为了计算实际的照明,我们将创建一个带有表面属性参数的GetLighting函数。首先让它返回曲面法线的Y分量。由于这是照明功能,我们将把它放在一个单独的Lighting.HLSL文件中。


#ifndef CUSTOM_LIGHTING_INCLUDED
#define CUSTOM_LIGHTING_INCLUDED

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

#endif


在LitPass中,我们在引用 surface后引用它,因为光照依赖于它。


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


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


float3 color = GetLighting(surface);
	return float4(color, surface.alpha);


为什么选择unity默认管线 unity自定义管线_数据_06


光源

要进行光照计算,我们还需要知道光的特性。在本教程中,我们将只讨论平行光。平行光表示光源离得很远,所以它的位置无关紧要,只与它的方向有关。它可以模拟地球上的太阳光和其它单向入射光。

这里我们将使用一个结构体来存储光源数据,现在我们只需要一个颜色和一个方向就足够了。把它放在一个单独的light.hlsl文件。还定义一个GetDirectionalLight函数,该函数返回配置的方向灯。光源默认颜色是白色和方向是向上,与我们当前使用的光线数据相匹配。请注意,光的方向这里被定义为光射入的方向,而不是光射出的方向。


#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


在litpass中引用Lighting之前引用这个文件


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


光照函数

为lighting添加一个GetIncomingLight函数,计算给定表面和入射光线的反射光。对于任意方向光的反射结果,我们需要取表面法线和方向的点积再乘以光的颜色。


float3 GetIncomingLight (Surface surface, Light light) {
	return dot(surface.normal, light.direction) * light.color;
}


但这个结果只在表面朝向光源的时候是正确的。当点积是负数时,我们必须使它等于零,这可以通过saturate函数来实现。


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


向GPU发送光源数据

我们应该使用当前场景的灯光,而不是总是使用来自我们设置的默认光。默认场景有一个方向灯,代表太阳,颜色稍微偏黄——fff4d6十六进制——绕X轴旋转50°,绕Y轴旋转30°。如果这样的光源不存在则创造一个。

为了使光源数据在着色器中可访问,我们必须为它创建统一值,就像着色器属性一样。在本例中,我们将定义两个float3类型的向量:_DirectionalLightColor和_DirectionalLightDirection。将它们放到定义在Light顶部的_CustomLight缓冲区中。


CBUFFER_START(_CustomLight)
	float3 _DirectionalLightColor;
	float3 _DirectionalLightDirection;
CBUFFER_END


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


Light GetDirectionalLight () {
	Light light;
	light.color = _DirectionalLightColor;
	light.direction = _DirectionalLightDirection;
	return light;
}


现在,我们的RP必须将光源数据发送到GPU。 我们将为此创建一个新的Lighting类。 它的工作方式与CameraRenderer相似,但适用于灯光照明。给它提供一个带有context参数的公共Setup方法,在该方法中它调用一个单独的SetupDirectionalLight方法。


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 () {}
}


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


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


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


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


光源的颜色属性是其配置的颜色,但是光源也具有单独的强度因子。 最终的颜色是两者的乘积。


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


为CameraRenderer提供一个Lighting实例,并在绘制几何图形之前使用它来设置光源信息。


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


为什么选择unity默认管线 unity自定义管线_为什么选择unity默认管线_07


在进行剔除时,Unity还会找出哪些光源会影响相机可见的空间。 我们可以依靠这些信息而不是全局的sun光源。 为此,Lighting需要访问剔除结果,我们为Setup添加一个剔除结果的输入参数,并将其存储在字段中以方便使用。然后,我们可以支持多个光源,让我们使用新的SetupLights方法来替换SetupDirectionalLight。


CullingResults cullingResults;

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


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


lighting.Setup(context, cullingResults);


现在Lighting.SetupLights可以通过剔除结果的visibleLights属性检索所需的数据。 它以的Unity.Collections.NativeArray的形式存在。


using Unity.Collections;
using UnityEngine;
using UnityEngine.Rendering;

public class Lighting {
	…

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

	…
}


多光源照射

使用可见光数据可以支持多个定向光,但是我们必须将所有这些光的数据发送到GPU。 因此,我们将使用两个Vector4数组,并用这两个数组来存储光源信息。让我们将最大值设置为四个,这对于大多数场景来说应该足够了。


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];


将索引和VisibleLight参数传递给SetupDirectionalLight。 用提供的索引设置颜色和光照方向。在这种情况下,最终颜色是通过VisibleLight.finalColor属性提供的。 可以通过VisibleLight.localToWorldMatrix属性找到前向矢量。 它是矩阵的第三列,必须再次取反。


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


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


public CustomRenderPipeline (
		bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher
	) {
		this.useDynamicBatching = useDynamicBatching;
		this.useGPUInstancing = useGPUInstancing;
		GraphicsSettings.useScriptableRenderPipelineBatching = useSRPBatcher;
		GraphicsSettings.lightsUseLinearIntensity = true;
	}


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


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


但是我们最多只支持四个定向灯,因此当达到最大值时,我们应该中止循环。 让我们添加一个与循环的迭代器分开的索引。


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来做到这一点。


VisibleLight visibleLight = visibleLights[i];
if (visibleLight.lightType == LightType.Directional) {
	SetupDirectionalLight(dirLightCount++, visibleLight);
	if (dirLightCount >= maxDirLightCount) {
		break;
	}
}


这样虽然可行,但是VisibleLight结构相当大。 理想情况下,我们只从本地数组中检索一次,并且也不要将其作为常规参数传递给SetupDirectionalLight,因为那样会对其进行复制。 我们可以通过引用传递参数。


SetupDirectionalLight(dirLightCount++, ref visibleLight);


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


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


Shader循环

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


#define MAX_DIRECTIONAL_LIGHT_COUNT 4

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


添加一个函数以获取定向光照计数并调整GetDirectionalLight,以便它检索特定光照索引的数据。


int GetDirectionalLightCount () {
	return _DirectionalLightCount;
}

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


然后调整曲面的GetLight,使其使用for循环来累积所有定向光的贡献。


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


为什么选择unity默认管线 unity自定义管线_为什么选择unity默认管线_08


现在,我们的着色器最多支持四个平行光。 通常只需要一个平行光来表示太阳或月球,但是也可能存在行星上有多个太阳的场景。 定向灯也可以用于近似多个大型照明设备,例如大型体育场的照明设备。

如果游戏始终只有一个平行光,那么你可以摆脱循环,或者制作多个着色器变体。 但是对于本教程,我们为了简单坚持一个通用循环。 最好的性能总是通过剔除不需要的内容来实现的。

Shader的目标等级

着色器曾经遇到过长度可变的循环问题,但是现在的GPU可以毫无问题地处理它们,尤其是当绘制的所有片段以相同的方式遍历同一数据时。但是,默认情况下,OpenGL ES 2.0和WebGL 1.0图形API不能处理此类循环。我们可以通过合并硬编码的最大值来使其工作,例如,使GetDirectionalLight返回min(_DirectionalLightCount,MAX_DIRECTIONAL_LIGHT_COUNT)。这样就可以展开循环,将其变成一系列条件代码块。不幸的是,生成的着色器代码一团糟,性能下降得很快。在非常老式的硬件上,所有代码块都将始终执行,它们的贡献可通过条件分配来控制。尽管我们可以进行这项工作,但它会使代码更加复杂,因为我们还必须进行其他调整。因此,为了简化起见,我选择忽略这些限制并在构建中关闭WebGL 1.0和OpenGL ES 2.0支持。他们不支持线性照明。我们还可以通过#pragma target 3.5指令将着色器传递的目标级别提高到3.5,从而避免为它们编译OpenGL ES 2.0着色器变体。让我们保持一致,并为两个着色器执行此操作。


HLSLPROGRAM
			#pragma target 3.5
			…
			ENDHLSL