Unity SRP系列——方向光

本文详细介绍了Unity的通用渲染管线(URP)中自定义光照的实现过程,包括方向光的设置、表面属性定义、BRDF模型、透明度处理以及ShaderGUI的定制。内容涵盖从创建Shader到控制材质属性,再到光照计算和渲染队列的调整,全面揭示了URP中的光照流水线工作原理。
摘要由CSDN通过智能技术生成

 实例原文

Unity通用渲染管线(URP)系列(三)——方向光(Direct Illumination) - 知乎 (zhihu.com)

Directional Lights (catlikecoding.com)

光照

定义Lit.shader和Lit.hlsl文件。

将着色器的照明模式设置为CustomLit来进行说明自定义光照。向Pass里添加一个Tag块,其中包含“ LightMode” =“ CustomLit”。

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

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

使用SpaceTransforms中的TransformObjectToWorldNormal在LitPassVertex中将法线转换到世界空间。

定义表面属性的hlsl文件Surface.hlsl

#ifndef CUSTOM_SURFACE_INCLUDED
#define CUSTOM_SURFACE_INCLUDED

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

#endif

在片元着色器中定义一个surface变量填充它。最终结果作为表面颜色和alpha值。

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

在进行实际的光照计算时,定义Lighting.hlsl文件:

#ifndef CUSTOM_LIGHTING_INCLUDED
#define CUSTOM_LIGHTING_INCLUDED

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

#endif

在片元着色器中获取光照并将其用于计算片段的RGB部分:

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

灯光

在Light.hlsl文件中定义灯光结构及获取灯光函数,

#ifndef CUSTOM_LIGHT_INCLUDED
#define CUSTOM_LIGHT_INCLUDED

struct Light {
	float3 color;
	float3 direction;
};

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

#endif

在Lighting 中添加IncomingLight函数,以计算给定的表面和光的入射数量。对于任意的方向的光,我们都需要用表面的法线和方向进行点乘(可以使用dot函数)。把结果和灯光的颜色进行混合。

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

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

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

最后,调整仅具有表面参数的GetLighting函数,以便使用GetDirectionalLight提供灯光数据来调用另一个参数。

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

为了使光源的数据可在着色器中访问,我们需要为其创建uniform 的值,就像着色器属性一样。在这种情况下,我们定义两个float3向量:_DirectionalLightColor和_DirectionalLightDirection。将它们放在Light.hlsl文件顶部定义的_CustomLight缓冲区中。

CBUFFER_START(_CustomLight)
	float4 _DirectionalLightColor;
	float4 _DirectionalLightDirection;
CBUFFER_END

使用这些值代替Light.hlsl文件中的GetDirectionalLight中的常量

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

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

Lighting lighting = new Lighting();


...


public void Render (
		ScriptableRenderContext context, Camera camera,
		bool useDynamicBatching, bool useGPUInstancing
) 
{
    ...   

    lighting.Setup(context, cullingResults);
    ...
}

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

调用:

	GraphicsSettings.lightsUseLinearIntensity = true;

现在,RP需要将灯光数据发送给GPU。它的工作方式与CameraRenderer相似,但只用于灯光

灯光的渲染流水线过程:

 

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

    const int maxDirLightCount = 4;

	static int
		dirLightCountId = Shader.PropertyToID("_DirectionalLightCount"),
		dirLightColorsId = Shader.PropertyToID("_DirectionalLightColors"),
		dirLightDirectionsId = Shader.PropertyToID("_DirectionalLightDirections");

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

在Lighting的Setup函数中,它调用一个单独的SetupLights方法。它提供一个专用的命令缓冲区,该缓冲区在完成后执行,可以很方便地进行调试。

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

而在SetupLights方法中:

Lighting.SetupLights可以通过剔除结果的visibleLights属性检索所需的数据。它以具有VisibleLight元素类型的Unity.Collections.NativeArray形式提供。接下来,遍历Lighting.SetupLights中的所有可见光,并为每个元素调用SetupDirectionalLight。然后在缓冲区上调用SetGlobalInt和SetGlobalVectorArray以将数据发送到GPU。

因为我们最多只支持四个方向灯,因此当达到最大值时,应该中止循环。让我们跟踪与循环的迭代器分开的方向光索引。

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

    void SetupLights () {
		NativeArray<VisibleLight> visibleLights = cullingResults.visibleLights;
		int dirLightCount = 0;
		for (int i = 0; i < visibleLights.Length; i++) {
			VisibleLight visibleLight = visibleLights[i];
			if (visibleLight.lightType == LightType.Directional) {
				SetupDirectionalLight(dirLightCount++, ref visibleLight);
				if (dirLightCount >= maxDirLightCount) {
					break;
				}
			}
		}

		buffer.SetGlobalInt(dirLightCountId, dirLightCount);
		buffer.SetGlobalVectorArray(dirLightColorsId, dirLightColors);
		buffer.SetGlobalVectorArray(dirLightDirectionsId, dirLightDirections);
	}

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

接下来,我们需要修改Shader中的部分,来跟新的数据格式匹配。

在Light.hlsl中调整_CustomLight缓冲区,将显式使用float4作为数组类型。着色器中的数组大小固定,无法调整大小。确保使用与Lighting中定义的最大值相同的最大值。

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

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

在Lit.shader中通过#pragma target 3.5指令将着色器传递的目标级别提高到3.5,从而避免为它们编译OpenGL ES 2.0着色器变体。

BRDF

入射光模型

完美的镜面反射

散乱的镜面反射 

向Lit.shader添加两个表面属性。

_Metallic ("Metallic", Range(0, 1)) = 0
_Smoothness ("Smoothness", Range(0, 1)) = 0.5

第一个属性是告知表面是金属的还是非金属的,也称为电介质。因为一个表面可以包含这两者的混合,所以我们将为其添加一个范围为0~1的滑块,其中1表示它是完全金属的。默认为全绝缘。
第二个属性控制表面的光滑程度。为此,我们还将使用范围为0~1的滑块,其中0完全粗糙,而1完全光滑。我们将使用0.5作为默认值。

在LitPass.hlsl中将属性添加到UnityPerMaterial缓冲区。

在Surface.hlsl中完善Surface结构。

在LitPass.hlsl的片元着色器中的LitPassFragment对它们赋值

新建一个BRDF.hlsl的文件,用它来计算BRDF方程。它告诉我们最终看到多少光从表面反射,这是漫反射和镜面反射的组合。我们需要将表面颜色分为漫反射和镜面反射部分,还需要知道表面的粗糙度。

#ifndef CUSTOM_BRDF_INCLUDED
#define CUSTOM_BRDF_INCLUDED

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

#endif

继续添加一个函数以获取给定表面的BRDF数据,假定目前是完美的漫反射表面,因此漫反射部分等于表面颜色,而镜面反射为黑色,粗糙度为1。

BRDF GetBRDF (Surface surface) {
	BRDF brdf;
	brdf.diffuse = suface.color;
    brdf.specular = 0.0;
    brdf.roughness = 1.0;
	return brdf;
}

在Lighting.hlsl文件中修改GetLighting函数,添加BRDF参数,然后将入射光与漫反射部分相乘。

float3 GetLighting (Surface surface, BRDF brdf, Light light) {
	return IncomingLight(surface, light) * brdf.diffuse;
}

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

最后,在LitPass.hlsl文件中的片元LitPassFragment函数获取BRDF数据,并将其传递给GetLighting。

不同的表面,反射的方式不同,但通常金属会通过镜面反射反射所有光,并且漫反射为零。因此,我们将声明反射率等于金属表面属性。被反射的光不会扩散,因此我们应将扩散色的缩放比例减去GetBRDF中的反射率一倍。 

实际上,一些光还会从电介质表面反射回来,从而使其具有亮点。非金属的反射率有所不同,但平均约为0.04。让我们将其定义为最小反射率,并添加一个OneMinusReflectivity函数,该函数将范围从0~1调整为0~0.96。此范围调整与Universal RP的方法匹配。

在GetBRDF中使用该函数可以强制执行最小值。仅渲染漫反射时,这种差异几乎不会引起注意,但是当我们添加镜面反射时,差异将非常重要。没有它,非金属将不会获得镜面反射高光。

以一种方式反射的光,不能全部以另一种方式反射。这称为能量转换,意味着出射光的量不能超过入射光的量。这表明镜面反射颜色应等于表面颜色减去漫反射颜色。

这忽略了金属会影响镜面反射的颜色而非金属不会影响镜面反射的颜色这一事实。介电表面的镜面颜色应为白色,这可以通过使用金属属性在最小反射率和表面颜色之间进行插值来实现。

计算粗糙度需减去一个平滑度即可。核心RP库具有一个执行此功能的函数,名为PerceptualSmoothnessToPerceptualRoughness。

修改BRDF.hlsl文件内容如下:

#define MIN_REFLECTIVITY 0.04

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

BRDF GetBRDF (Surface surface, bool applyAlphaToDiffuse = false) {
	BRDF brdf;
	float oneMinusReflectivity = OneMinusReflectivity(surface.metallic);

	brdf.diffuse = surface.color * oneMinusReflectivity;

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

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

	return brdf;
}

为了确定相机与完美反射方向对齐的程度,我们需要知道相机的位置。Unity通过float3 _WorldSpaceCameraPos使这些数据可用,因此将其添加到UnityInput.hlsl中。

float3 _WorldSpaceCameraPos;

为了获得视角方向(从表面到相机的方向),在LitPass.hlsl中,在Varyings结构中增加世界空间顶点的位置,在顶点着色器中增加对应的赋值:

在Surface.hlsl文件中,将视角方向视为表面数据的一部分,

在LitPass.hlsl的LitPassFragment中分配它。它等于相机位置减去片段位置(归一化)。

我们观察到的镜面反射的强度取决于我们的观察方向与完美反射方向的匹配程度。使用与Universal RP相同的公式,它是Minimalist CookTorrance BRDF的一种变体。该公式包含几个正方形,因此让我们首先向Common.hlsl中添加一个便捷的Square函数。

float Square (float x) {
	return x * x;
}

然后,以表面,BRDF数据和光照为参数,向BRDF添加SpecularStrength函数。它应该计算出

,其中r 是粗糙度,所有点积都应应用饱和。此外


,N是表面法线,L是光的方向,H = L + V归一化,这是光和视角方向之间的中途向量。使用SafeNormalize函数对矢量进行归一化,以防在矢量相对的情况下被零除。最后,n = 4 r+2 ,是一个归一化项。

在BRDF.hlsl中添加相应的计算方法:

float SpecularStrength (Surface surface, BRDF brdf, Light light) {
	float3 h = SafeNormalize(light.direction + surface.viewDirection);
	float nh2 = Square(saturate(dot(surface.normal, h)));
	float lh2 = Square(saturate(dot(light.direction, h)));
	float r2 = Square(brdf.roughness);
	float d2 = Square(nh2 * (r2 - 1.0) + 1.00001);
	float normalization = brdf.roughness * 4.0 + 2.0;
	return r2 / (d2 * max(0.1, lh2) * normalization);
}

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

为MeshBall添加各种金属和平滑度属性的支持。

透明度

这里需要再次考虑透明度。对象仍会根据其Alpha值淡入,但是现在是反射光就消失了。这对于漫反射是有意义的,因为只有一部分光被反射,而其余的光则穿过了表面。

但是,镜面反射也同样会消失。如果是完全透明的玻璃,则光线会穿过或反射。镜面反射不会消失。我们不能用我们目前的方法来呈现这一点。

解决方案是仅让diffuse 光褪色,同时使specular 反射保持全强度。由于源混合模式适用于所有我们无法使用的模式,因此我们将其设置为1,同时仍将目标混合模式使用one-minus-source-alpha。

这样可以恢复镜面反射,但是漫反射不再消失。通过将表面Alpha分解为漫反射颜色来解决此问题。因此,将Alpha预先乘以diffuse,而不是以后依赖GPU混合。这种方法称为预乘alpha混合。在GetBRDF中进行。

预乘切换

将Alpha与diffuse 进行预乘可有效地将对象变成玻璃,而常规Alpha混合可使对象实际上仅部分存在。通过为GetBRDF添加一个布尔参数来控制是否预乘alpha,默认情况下将其设置为false来支持这两种方法。

我们可以使用_PREMULTIPLY_ALPHA关键字来决定在LitPass.hlsl的LitPassFragment中使用哪种方法,类似于我们如何控制alpha裁剪一样。

给着色器的新功能添加关键字到Lit的Pass里。

向材质球添加一个toggle属性。

ShaderGUI

在Lit着色器的主块中添加一个CustomEditor“ CustomShaderGUI”语句。

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

该类必须扩展ShaderGUI并覆盖公共的OnGUI方法,该方法具有MaterialEditor和MaterialProperty数组参数。让它调用基本方法,因此我们得到了默认的检查器。

要完成任务,我们需要访问三项内容,并将其存储在字段中。首先是材质编辑器,它是负责显示和编辑材质的基础编辑器对象。其次是对正在编辑的材质的引用,我们可以通过编辑器的targets属性来检索它们。因为target是通用Editor类的属性,所以将其定义为Object数组。第三是可以编辑的属性数组。

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

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

再创建一个SetProperty重载方法,以切换属性-关键字组合。

现在,我们可以定义简单的Clipping,PremultiplyAlpha,SrcBlend,DstBlend和ZWrite setter属性。

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

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

从默认的不透明模式开始为每个预设创建一个单独的方法。设置适当激活后属性。

第二个预设是Clip,它是Opaque的副本,其中裁剪已打开并且队列设置为AlphaTest。

第三个预设是用于标准透明度的,可以淡化对象,因此我们将其命名为“Fade”。它是Opaque的另一个副本,具有调整的混合模式和队列,并且没有深度写入。

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

在OnGUI的末尾调用预设方法,使它们显示在默认检查器下方。

预设按钮不会经常使用,因此让我们将其放入默认的折叠中。这是通过调用具有当前折叠状态,标签和EditorGUILayout.Foldout为true来完成的,前面小的箭头指示,单击它可以切换其状态。因为它会返回新的折叠状态,所以应该将其存储在字段中。仅在折页打开时才绘制按钮。

还可以将自定义着色器GUI用于Unlit着色器。

但是,如果激活预设会导致错误,因为我们正在尝试设置着色器没有的属性。可以通过调整SetProperty来防止这种情况。让它使用false作为附加参数调用FindProperty,指示如果找不到该属性,则不应记录错误。结果将为空,只有在检测到的时候才设置该值。当然还返回属性是否存在。

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

现在,这些预设也适用于使用“Unlit”着色器的材质了,但“Transparent ”模式在这个Shader下没有意义,因为相关属性根本不存在。我们再改造下让它把无关的预设隐藏。

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

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

最后,通过对TransparentPreset进行检查,以便决定Transparent预设的显示情况。

 整个光照流水线流程:

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值