【跟着catlikecoding学渲染#4】多光源

上一个部分讲了单项光,这个部分就讲讲多光源

一,Include Files

若要向着色器添加对多个光源的支持,我们必须向其添加更多通道。这些传递最终将包含几乎相同的代码。为了防止代码重复,我们将着色器代码移动到包含文件

  Unity 没有用于创建着色器包含文件的菜单选项。因此,您可以在与照明着色器相同的文件夹中创建一个 My Lighting.cginc 纯文本文件。

  将照明着色器的所有代码复制到此文件,从#pragma语句的正下方直到 ENDCG。由于此代码不再直接位于着色器通道中

#include "UnityPBSLighting.cginc"

…

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
	…
}

现在,我们可以将此文件包含在着色器中,替换以前存在的代码。因为它位于同一文件夹中,所以我们可以直接引用它。

CGPROGRAM

			#pragma target 3.0

			#pragma vertex MyVertexProgram
			#pragma fragment MyFragmentProgram

			#include "My Lighting.cginc"

			ENDCG

1.1 Preventing Redefinitions

为了防止代码重定义带来的编译器错误,我们通常可以使用定义检查来保护包含文件,这是一个预处理器检查,来检查是都已做出某个定义,该定义只是一个与包含文件的名称相对应的唯一标识符,你可以把它定义为任何东西

#define MY_LIGHTING_INCLUDED

#include "UnityPBSLighting.cginc"

…

现在,我们可以将包含文件的全部内容放在预处理器 if 块中。条件是尚未定义MY_LIGHTING_INCLUDED。

#if !defined(MY_LIGHTING_INCLUDED)
#define MY_LIGHTING_INCLUDED

#include "UnityPBSLighting.cginc"

…

#endif

二,The Second Light

我们的第二盏灯将再次成为定向光。复制主光源并更改其颜色和旋转,以便您可以区分它们,将其强度滑块降低,例如降低到 0.8。Unity 将使用强度自动确定主光源。

即使我们有两个定向灯,也没有视觉上的差异。我们可以独立地看到他们的光,一次只有一个活动。但是当两者都处于活动状态时,只有主光具有任何效果。

2.1A Second Pass 

我们只看到一个光源,因为我们的着色器只计算一个光源。前向基础pass用于主定向光。要渲染额外的光源,我们需要一个额外的通道。 复制我们的着色器密码,并将新着色器的灯光模式设置为“前向添加”。Unity 将使用此通道来渲染额外的光源。

SubShader {

		Pass {
			Tags {
				"LightMode" = "ForwardBase"
			}

			CGPROGRAM

			#pragma target 3.0

			#pragma vertex MyVertexProgram
			#pragma fragment MyFragmentProgram

			#include "My Lighting.cginc"

			ENDCG
		}

		Pass {
			Tags {
				"LightMode" = "ForwardAdd"
			}

			CGPROGRAM

			#pragma target 3.0

			#pragma vertex MyVertexProgram
			#pragma fragment MyFragmentProgram

			#include "My Lighting.cginc"

			ENDCG
		}
	}

我们现在看到的是次光,而不是主光。Unity 会渲染两者,但加法传递最终会覆盖基本通道的结果。这是错误的。加法pass必须将其结果添加到基础pass中,而不是替换它。我们可以通过更改加法通道的混合模式来指示 GPU 执行此操作。 新旧像素数据的组合方式由两个因素定义。新旧数据与这些因素相乘,然后相加成为最终结果。默认模式为无混合,相当于 One Zero。此类传递的结果将替换以前在帧缓冲区中的任何内容。要添加到帧缓冲区,我们必须指示它使用 One One 混合模式。这被称为添加剂混合。

Tags {
				"LightMode" = "ForwardAdd"
			}

			Blend One One

			CGPROGRAM

首次渲染对象时,GPU 会检查片段是否最终出现在已渲染到该像素的任何其他内容的前面。此距离信息存储在 GPU 的深度缓冲区(也称为 Z 缓冲区)中。因此,每个像素都有颜色和深度。此深度表示每个像素到距相机最近表面的距离。它就像声纳。 如果我们想要渲染的片段前面没有任何内容,那么它目前是最接近相机的表面。GPU 继续运行片段程序。它会覆盖像素的颜色,并记录其新深度。 如果碎片最终比已经存在的东西更远,那么它前面就有东西。在这种情况下,我们无法看到它,并且它根本不会被渲染。

What about semitransparent objects?

深度缓冲区方法仅适用于完全不透明的对象。半透明对象需要不同的方法。我们将在以后的教程中处理这些问题。


这个过程对辅助光重复,除了现在我们正在添加已经存在的东西。再一次,片段程序只有在我们渲染的内容前面没有任何东西时才会运行。如果是这样,我们最终会达到与上一个通道完全相同的深度,因为它是针对同一个对象的。因此,我们最终记录完全相同的深度值。 由于不需要向深度缓冲区写入两次,因此让我们禁用它。这是通过 ZWrite Off 着色器语句完成的。

Blend One One
ZWrite Off

2.2 Draw Call Batches

为了更好地了解发生了什么,您可以启用游戏视图右上角的“统计信息”面板。查看批次的数量,以及通过批处理节省的批次数量。它们表示绘制调用。仅在主灯处于活动状态的情况下执行此操作。

由于我们有六个对象,因此您需要六个批次。但启用动态批处理后,所有三个多维数据集将合并为一个批处理。因此,您预计会有四个批次,其中两个已保存。但您可能有五个批次。 额外的批处理是由动态阴影引起的。让我们通过编辑/项目设置/质量在质量设置中完全禁用阴影来消除它。确保调整当前在编辑器中使用的质量设置。

Why do I still have an additional batch?

有可能是因为你正在渲染环境cube map,这是另外的draw call,我们可以禁用它


由于每个对象现在渲染两次,因此我们最终得到十二个批次,而不是六个批次。这是预料之中的。您可能没有预料到的是,动态批处理不再起作用。遗憾的是,Unity 的动态批处理仅适用于最多受单个定向光源影响的对象。激活第二盏灯使得这种优化变得不可能。

2.3 Frame Debugger

若要更好地了解场景的渲染方式,可以使用帧调试器。通过窗口/帧调试器打开它。

启用后,帧调试器允许您单步执行每个单独的绘制调用。窗口本身显示每个绘制调用的详细信息。游戏视图将显示呈现到所选绘制调用(包括所选绘制调用)的内容。

  首先绘制靠近相机的不透明对象。这种从前到后的绘制顺序是有效的,因为由于深度缓冲区,隐藏的碎片将被跳过。如果我们从后到前绘制,我们会继续覆盖更远物体的像素。这称为透支,应尽可能避免。 Unity 对对象进行从前到后的排序,但这并不是决定绘制顺序的唯一因素。更改GPU状态也很昂贵,也应该尽量减少。这是通过将相似的对象呈现在一起来完成的。例如,Unity 更喜欢按组渲染球体和立方体,因为这样它就不必经常在网格之间切换。同样,Unity 更喜欢对使用相同材质的对象进行分组。

三,Point Lights

定向光并不是唯一的光类型。让我们通过游戏对象/光源/点光源添加点光源。

  当我们移动点光源时,我们会发现光线表现得很奇怪。这是怎么回事?当您使用帧调试器时,您会注意到我们的对象首先呈现为纯黑色,然后再次呈现为奇怪的光线。 第一个传递是基本通道。它始终被渲染,即使没有活动的方向光。所以我们最终得到了一个黑色的轮廓。 第二次通过再次是我们的加法通道。这一次,它使用点光源而不是定向光源。但是我们的代码仍然假设一个定向光。我们必须解决这个问题。

3.1 Light Function

由于我们的光会变得更加复杂,因此让我们将创建它的代码移动到一个单独的函数中。将此函数直接放在 MyFragmentProgram 函数的上方。

UnityLight CreateLight (Interpolators i) {
	UnityLight light;
	light.dir = _WorldSpaceLightPos0.xyz;
	light.color = _LightColor0.rgb;
	light.ndotl = DotClamped(i.normal, light.dir);
	return light;
}
float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
	i.normal = normalize(i.normal);
//	float3 lightDir = _WorldSpaceLightPos0.xyz;
	float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);

//	float3 lightColor = _LightColor0.rgb;
	float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;

	float3 specularTint;
	float oneMinusReflectivity;
	albedo = DiffuseAndSpecularFromMetallic(
		albedo, _Metallic, specularTint, oneMinusReflectivity
	);

//	UnityLight light;
//	light.color = lightColor;
//	light.dir = lightDir;
//	light.ndotl = DotClamped(i.normal, lightDir);

	UnityIndirect indirectLight;
	indirectLight.diffuse = 0;
	indirectLight.specular = 0;

	return UNITY_BRDF_PBS(
		albedo, specularTint,
		oneMinusReflectivity, _Smoothness,
		i.normal, viewDir,
		CreateLight(i), indirectLight
	);
}

3.2 Light Position

_WorldSpaceLightPos0变量包含当前光源的位置。但是在定向光的情况下,它实际上保持了朝向光的方向。现在我们使用的是点光源,该变量实际上包含其名称所暗示的数据。因此,我们必须自己计算光的方向。这是通过减去片段的世界位置并对结果进行归一化来完成的。

light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);

3.3 Light Attenuation

在定向光的情况下,知道它的方向就足够了。它被假定为无限远。但点光源具有明确的位置。这意味着它与物体表面的距离也会产生影响。光线越远,它就越暗淡。这被称为光的衰减。 在方向光的情况下,假设衰减变化得很慢,我们可以将其视为常数。所以我们不为此烦恼。但是点光源的衰减是什么样的呢?

  想象一下,我们从一个点发射出一个光子爆发。这些光子向各个方向移动。随着时间的流逝,光子离该点越来越远。当它们都以相同的速度行进时,光子充当球体的表面,球体的中心是点。这个球体的半径随着光子的不断移动而增加。随着球体的生长,其表面也会随之增长。但是这个表面总是包含相同数量的光子。所以光子的密度降低了。这决定了观察到的光的亮度。

UnityLight CreateLight (Interpolators i) {
	UnityLight light;
	light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
	float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos;
	float attenuation = 1 / (dot(lightVec, lightVec));
	light.color = _LightColor0.rgb * attenuation;
	light.ndotl = DotClamped(i.normal, light.dir);
	return light;
}

这在靠近光线的地方产生非常明亮的效果。发生这种情况是因为当距离接近零时,衰减因子会喷射到无穷大。要确保光的强度在零距离处达到最大值,请将衰减方程更改为1/(1+d2

	float attenuation = 1 / (1 + dot(lightVec, lightVec));

3.4Light Range 

在现实生活中,光子不断移动,直到它们击中某些东西。这意味着光的范围可能是无限的,即使它变得如此微弱,以至于我们再也看不到它。但是我们不想浪费时间渲染我们看不见的灯光。

  因此,我们必须在某个时候停止渲染它们。 点光源和聚光灯有一个范围。位于此范围内的对象将使用此光源获得绘制调用。所有其他对象都不会。默认范围为 10。此范围越小,获得额外绘制调用的对象就越少,从而导致更高的帧速率。将光源的范围设置为 1 并四处移动。

当物体进入和离开范围时,您将清楚地看到,因为它们会突然在被点亮和未点亮之间切换。发生这种情况是因为光仍然在我们选择的范围之外可见。要解决此问题,我们必须确保衰减和范围同步。 实际上,光线没有最大范围。

  因此,我们设定的任何范围都是自由的。然后,我们的目标就变成了确保当物体移出范围时,不会突然出现光转换。这要求衰减因子在最大范围内达到零。 Unity 通过将片段的世界位置转换为光源空间位置来确定点光源的衰减。

  这是光物体局部空间中的一个点,通过其衰减进行缩放。在这个空间中,点光源位于原点。任何距离它超过一个单位的东西都超出了范围。因此,与原点的平方距离定义了缩放衰减因子。

  Unity 更进一步,使用平方距离对衰减纹理进行采样。这样做是为了确保衰减尽早降至零。如果没有此步骤,当物体移入或移出范围时,您仍然可能会发出灯光。 此技术的代码可在 AutoLight 包含文件中找到。让我们使用它,而不是自己写它。

#include "AutoLight.cginc"
#include "UnityPBSLighting.cginc"

现在,我们可以访问UNITY_LIGHT_ATTENUATION宏。此宏插入代码以计算正确的衰减因子。它有三个参数。第一个是将包含衰减的变量的名称。我们将为此使用衰减。第二个参数与阴影有关。由于我们还不支持这些,因此只需使用零。第三个参数是世界位置。 请注意,该宏在当前作用域中定义变量。所以我们不应该再自己声明了。

UnityLight CreateLight (Interpolators i) {
	UnityLight light;
	light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
//	float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos;
//	float attenuation = 1 / (dot(lightVec, lightVec));
	UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
	light.color = _LightColor0.rgb * attenuation;
	light.ndotl = DotClamped(i.normal, light.dir);
	return light;
}

What does UNITY_LIGHT_ATTENUATION look like?

#ifdef POINT
uniform sampler2D _LightTexture0;
uniform unityShadowCoord4x4 unity_WorldToLight;
#define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
	unityShadowCoord3 lightCoord = \
		mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xyz; \
	fixed destName = \
		(tex2D(_LightTexture0, dot(lightCoord, lightCoord).rr). \
		UNITY_ATTEN_CHANNEL * SHADOW_ATTENUATION(input));
#endif

阴影坐标类型在别处定义。它们要么是全精度浮点,要么是半精度浮点。 点积生成单个值。rr swizzle只是简单地复制它,所以你最终会得到一个float2。然后,这用于对衰减纹理进行采样。由于纹理数据是一维的,因此其第二个坐标无关紧要。 UNITY_ATTEN_CHANNEL为 r 或 a,具体取决于目标平台。 由于我们不支持阴影,因此宏SHADOW_ATTENUATION变为 1,可以忽略。


使用此宏后,衰减似乎不再起作用。那是因为它有多个版本,每种光源类型一个。默认情况下,它用于方向光,它根本没有衰减。 只有当知道我们正在处理点光源时,才会定义正确的宏。为了表明这一点,我们必须在包含AutoLight之前#define POINT。由于我们只在加法通道中处理点光源,因此在包含“我的光源”之前,请在此处定义它。

Pass {
			Tags {
				"LightMode" = "ForwardAdd"
			}

			Blend One One
			ZWrite Off

			CGPROGRAM

			#pragma target 3.0

			#pragma vertex MyVertexProgram
			#pragma fragment MyFragmentProgram

			#define POINT

			#include "My Lighting.cginc"

			ENDCG
		}

四,Mixing Lights 

关闭点灯,再次激活我们的两个定向灯。

出了点问题。我们将它们的光照方向解释为位置。辅助定向光(由加法pass渲染)完全被视为点光源。为了解决这个问题,我们必须为不同的光照类型创建着色器变体。

4.1 Shader Variants

在检查器中检查我们的着色器。“编译并显示代码”按钮下的下拉菜单包含一个部分,告诉我们它当前有多少个着色器变体。单击“显示”按钮以获取它们的概述。

// Total snippets: 2
// -----------------------------------------
// Snippet #0 platforms ffffffff:

Just one shader variant.


// -----------------------------------------
// Snippet #1 platforms ffffffff:

Just one shader variant.

打开的文件告诉我们有两个代码段,每个代码段都有一个着色器变体。这些是我们的基础和附加通道。 我们希望为加法通道创建两个着色器变体。一个用于定向光源,一个用于点光源。我们通过向 pass 中添加多编译编译杂注语句来实现此目的。此语句定义关键字列表。Unity 将为我们创建多个着色器变体,每个变体定义其中一个关键字。 每个变体都是一个单独的着色器。它们是单独编译的。它们之间的唯一区别是定义了哪些关键字。 在这种情况下,我们需要 DIRECTION 和 POINT,我们不应该再自己定义 POINT。

Pass {
			Tags {
				"LightMode" = "ForwardAdd"
			}

			Blend One One
			ZWrite Off

			CGPROGRAM

			#pragma target 3.0

			#pragma multi_compile DIRECTIONAL POINT

			#pragma vertex MyVertexProgram
			#pragma fragment MyFragmentProgram

//			#define POINT

			#include "My Lighting.cginc"

			ENDCG
		}

再次调用着色器变体概述。这一次,第二个代码段将包含两个变体,如我们要求的那样。

// Total snippets: 2
// -----------------------------------------
// Snippet #0 platforms ffffffff:

Just one shader variant.


// -----------------------------------------
// Snippet #1 platforms ffffffff:
DIRECTIONAL POINT

2 keyword variants used in scene:

DIRECTIONAL
POINT

4.2 Using Keywords

我们可以检查这些关键字中存在哪些,就像AutoLight对POINT所做的那样。在我们的例子中,如果定义了POINT,那么我们必须自己计算光方向。否则,我们有一个定向光,_WorldSpaceLightPos0就是方向。

UnityLight CreateLight (Interpolators i) {
	UnityLight light;
	
	#if defined(POINT)
		light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
	#else
		light.dir = _WorldSpaceLightPos0.xyz;
	#endif
	
	float3 lightVec = _WorldSpaceLightPos0.xyz - i.worldPos;
	UNITY_LIGHT_ATTENUATION(attenuation, 0, i.worldPos);
	light.color = _LightColor0.rgb * attenuation;
	light.ndotl = DotClamped(i.normal, light.dir);
	return light;
}

这适用于我们的两种增材加通变体。它也适用于基本通道,因为它没有定义 POINT。 Unity 根据当前光源和着色器变体关键字决定要使用的变体。渲染定向光源时,它使用定向变体。渲染点光源时,它使用 POINT 变体。当没有匹配项时,它只会从列表中选择第一个变体。

五,Spotlights

除了定向灯和点光源外,Unity 还支持聚光灯。聚光灯就像点光源,只是它们被限制在锥体上,而不是向所有方向照射。

  这些仅支持静态光照映射。我们将在以后的教程中介绍该主题。

为了支持聚光灯,我们必须将 SPOT 添加到多编译语句的关键字列表中。

#pragma multi_compile DIRECTIONAL POINT SPOT

我们的additive着色器现在有三种变体。

// Snippet #1 platforms ffffffff:
DIRECTIONAL POINT SPOT

3 keyword variants used in scene:

DIRECTIONAL
POINT
SPOT

聚光灯有一个位置,就像点光源一样。因此,当定义POINT或SPOT时,我们必须计算光照方向。

#if defined(POINT) || defined(SPOT)
		light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
	#else
		light.dir = _WorldSpaceLightPos0.xyz;
	#endif

这已经足以让聚光灯发挥作用。它们最终得到一个不同的UNITY_LIGHT_ATTENUATION宏,它负责锥形形状。 衰减方法的开始与点光源的衰减方法相同。转换为光空间,然后计算衰减因子。然后,将原点后面的所有点的衰减强制为零。这限制了聚光灯前的一切光线。 然后,将光空间中的 X 和 Y 坐标用作 UV 坐标来对纹理进行采样。此纹理用于遮罩光线。纹理只是一个边缘模糊的圆圈。这产生了一个轻型圆柱体。要把它变成一个圆锥体,向光空间的转换实际上是一种透视转换,并使用均匀的坐标。

What does UNITY_LIGHT_ATTENUATION look like for spotlights?

在这里。请注意对蒙版纹理进行采样时从均匀坐标到欧氏坐标的转换。在此之后添加1/2,使纹理居中。

#ifdef SPOT
uniform sampler2D _LightTexture0;
uniform unityShadowCoord4x4 unity_WorldToLight;
uniform sampler2D _LightTextureB0;
inline fixed UnitySpotCookie(unityShadowCoord4 LightCoord) {
return tex2D(_LightTexture0, LightCoord.xy / LightCoord.w + 0.5).w;
}
inline fixed UnitySpotAttenuate(unityShadowCoord3 LightCoord) {
return tex2D(_LightTextureB0, dot(LightCoord, LightCoord).xx). \
	UNITY_ATTEN_CHANNEL;
}
#define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
unityShadowCoord4 lightCoord = \
	mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)); \
fixed destName = (lightCoord.z > 0) * UnitySpotCookie(lightCoord) * \
	UnitySpotAttenuate(lightCoord.xyz) * SHADOW_ATTENUATION(input);
#endif

5.1 Spotlight Cookies

默认的聚光灯蒙版纹理是一个模糊的圆圈。但是你可以使用任何正方形纹理,只要它在边缘处下降到零。这些纹理被称为聚光灯饼干。这个名字来源于cucoloris,它指的是为灯光添加阴影的电影,剧院或摄影道具。 Cookie的阿尔法通道用于遮挡光线。其他渠道无关紧要。下面是一个纹理示例,它的所有四个通道都设置为相同的值。

导入纹理时,您可以选择 Cookie 作为其类型。然后,您还必须设置其光源类型,在本例中为Spotlight。然后,Unity 将为您处理大多数其他设置。

六,More Cookies

定向灯也可以有cookies。这些cookies是平铺的。因此,它们不需要在边缘淡入零。相反,他们必须无缝平铺。

定向灯的cookies有一个大小。这决定了它们的视觉大小,这反过来又会影响它们的平铺速度。默认值为 10,但小场景需要小得多的比例,如 1。

带有 cookie 的定向光源还必须执行到光源空间的转换。因此,它有自己的UNITY_LIGHT_ATTENUATION宏。因此,Unity 将其视为与没有 Cookie 的定向光源不同的光源类型。因此,它们将始终由加法传递呈现,使用DIRECTIONAL_COOKIE关键字

			#pragma multi_compile DIRECTIONAL DIRECTIONAL_COOKIE POINT SPOT

 

What does UNITY_LIGHT_ATTENUATION look like in this case?

由于没有衰减,因此仅对cookies进行采样。

#ifdef DIRECTIONAL_COOKIE
uniform sampler2D _LightTexture0;
uniform unityShadowCoord4x4 unity_WorldToLight;
#define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
	unityShadowCoord2 lightCoord = \
		mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xy; \
	fixed destName = \
		tex2D(_LightTexture0, lightCoord).w * SHADOW_ATTENUATION(input);
#endif

6.1 Cookies for Point Lights

点光源也可以有cookies。在这种情况下,光线向各个方向传播,因此cookies必须包裹在球体周围。这是通过使用多维数据集映射完成的。 您可以使用各种纹理格式来创建点光源 Cookie,Unity 会将其转换为立方体贴图。您必须指定映射,以便 Unity 知道如何解释您的图像。最好的方法是自己提供立方体映射,在这种情况下,您可以使用自动映射模式。

点光源 Cookie 没有任何其他设置。此时,我们必须将 POINT_COOKIE 关键字添加到多编译语句中。它正在成为一个相当长的清单。因为它是一个如此常见的列表,所以Unity为我们提供了一个速记编译指示语句,我们可以改用它。

	#pragma multi_compile_fwdadd
//			#pragma multi_compile DIRECTIONAL DIRECTIONAL_COOKIE POINT SPOT

您可以验证这确实产生了我们需要的五个变体。

// Snippet #1 platforms ffffffff:
DIRECTIONAL DIRECTIONAL_COOKIE POINT POINT_COOKIE SPOT

5 keyword variants used in scene:

POINT
DIRECTIONAL
SPOT
POINT_COOKIE
DIRECTIONAL_COOKIE

不要忘记使用 cookie 计算点光源的光照方向。

#if defined(POINT) || defined(POINT_COOKIE) || defined(SPOT)
		light.dir = normalize(_WorldSpaceLightPos0.xyz - i.worldPos);
	#else
		light.dir = _WorldSpaceLightPos0.xyz;
	#endif

What does UNITY_LIGHT_ATTENUATION look like in this case?

它等效于常规点光源的微距,只是它还对 Cookie 进行采样。由于在这种情况下,Cookie 是立方体映射,因此它使用 texCUBE 来执行此操作。

#ifdef POINT_COOKIE
uniform samplerCUBE _LightTexture0;
uniform unityShadowCoord4x4 unity_WorldToLight;
uniform sampler2D _LightTextureB0;
#define UNITY_LIGHT_ATTENUATION(destName, input, worldPos) \
	unityShadowCoord3 lightCoord = \
		mul(unity_WorldToLight, unityShadowCoord4(worldPos, 1)).xyz; \
	fixed destName = \
		tex2D(_LightTextureB0, dot(lightCoord, lightCoord).rr). \
		UNITY_ATTEN_CHANNEL * texCUBE(_LightTexture0, lightCoord).w *
		SHADOW_ATTENUATION(input);
#endif

七,Vertex Lights

每个可见对象始终以其基本通道进行渲染。此通道负责主方向光。每增加一盏灯,都会在此基础上增加一个额外的添加剂通道。因此,许多光源将导致许多绘制调用。许多光源在其范围内具有许多对象将导致大量的绘制调用。

  以具有四个点光源和六个对象的场景为例。所有对象都在所有四个光源的范围内。这需要每个对象进行五次绘制调用。一个用于基础刀路,外加四个附加刀路。总共有 30 个抽奖调用。请注意,您可以向其添加单个方向光源,而不会增加绘制调用。

要控制绘制调用的数量,可以通过质量设置来限制像素光数。这定义了每个对象使用的最大像素光源量。当光源按片段计算时,光源称为像素光源。 质量级别越高,像素光源越多。最高质量级别的默认值为四个像素光源。

对于每个对象,渲染哪些光源是不同的。Unity 根据光源的相对强度和距离对光源进行从最高到最不重要的排序。预计贡献最小的灯将首先被丢弃。 实际上,还会发生一些事情,但我们稍后会谈到这一点。   

  由于不同的对象会受到不同光源的影响,因此您将获得不一致的光源。当事物处于运动状态时,情况会变得更糟,因为它可能导致照明的突然变化。 这个问题太严重了,因为灯完全关闭了。幸运的是,还有另一种方法可以更便宜地渲染灯光,而无需完全关闭它们。我们可以按顶点而不是按片段渲染它们。

  按顶点渲染一个光源意味着在顶点程序中执行光源计算。然后对生成的颜色进行插值并传递给片段程序。这太便宜了,Unity在基本通道中包含了这样的灯光。发生这种情况时,Unity 会查找具有 VERTEXLIGHT_ON 关键字的基本通道着色器变体。 仅点光源支持顶点光源。因此,定向光源和聚光灯不能是顶点光源。 要使用顶点光源,我们必须向基通道添加一个多编译语句。它只需要一个关键字,VERTEXLIGHT_ON。另一个选项根本没有关键字。为了表明这一点,我们必须使用_

Pass {
			Tags {
				"LightMode" = "ForwardBase"
			}

			CGPROGRAM

			#pragma target 3.0

			#pragma multi_compile _ VERTEXLIGHT_ON

			#pragma vertex MyVertexProgram
			#pragma fragment MyFragmentProgram

			#include "My Lighting.cginc"

			ENDCG
		}

7.1 One Vertex Light

要将顶点光的颜色传递给片段程序,我们必须将其添加到插值器结构中。当然,只有在定义了VERTEXLIGHT_ON关键字时,才需要这样做。

struct Interpolators {
	float4 position : SV_POSITION;
	float2 uv : TEXCOORD0;
	float3 normal : TEXCOORD1;
	float3 worldPos : TEXCOORD2;

	#if defined(VERTEXLIGHT_ON)
		float3 vertexLightColor : TEXCOORD3;
	#endif
};

让我们创建一个单独的函数来计算此颜色。它既从插值器读取插值,又写入插值器,因此这成为一个 inout 参数。

void ComputeVertexLightColor (inout Interpolators i) {
}

Interpolators MyVertexProgram (VertexData v) {
	Interpolators i;
	i.position = mul(UNITY_MATRIX_MVP, v.position);
	i.worldPos = mul(unity_ObjectToWorld, v.position);
	i.normal = UnityObjectToWorldNormal(v.normal);
	i.uv = TRANSFORM_TEX(v.uv, _MainTex);
	ComputeVertexLightColor(i);
	return i;
}

现在,我们将简单地传递第一个顶点光源的颜色。只有当光存在时,我们才能做到这一点。否则,我们继续无所事事。UnityShaderVariables 定义了一组顶点浅色。这些是RGBA颜色,但我们只需要RGB部分。

void ComputeVertexLightColor (inout Interpolators i) {
	#if defined(VERTEXLIGHT_ON)
		i.vertexLightColor = unity_LightColor[0].rgb;
	#endif
}

在片段程序中,我们必须将此颜色添加到我们在那里计算的所有其他光源中。我们可以通过将顶点光色视为间接光来做到这一点。将间接照明数据的创建移动到其自己的函数。在其中,将顶点光源颜色指定给间接漫射分量(如果存在)。

UnityIndirect CreateIndirectLight (Interpolators i) {
	UnityIndirect indirectLight;
	indirectLight.diffuse = 0;
	indirectLight.specular = 0;

	#if defined(VERTEXLIGHT_ON)
		indirectLight.diffuse = i.vertexLightColor;
	#endif
	return indirectLight;
}

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
	i.normal = normalize(i.normal);
	float3 viewDir = normalize(_WorldSpaceCameraPos - i.worldPos);
	float3 albedo = tex2D(_MainTex, i.uv).rgb * _Tint.rgb;

	float3 specularTint;
	float oneMinusReflectivity;
	albedo = DiffuseAndSpecularFromMetallic(
		albedo, _Metallic, specularTint, oneMinusReflectivity
	);

//	UnityIndirect indirectLight;
//	indirectLight.diffuse = 0;
//	indirectLight.specular = 0;

	return UNITY_BRDF_PBS(
		albedo, specularTint,
		oneMinusReflectivity, _Smoothness,
		i.normal, viewDir,
		CreateLight(i), CreateIndirectLight(i)

将像素光计数设置为零。现在,每个对象都应渲染为具有单个光源颜色的轮廓。

Unity 以这种方式最多支持四个顶点光源。这些光源的位置存储在四个 float4 变量中,每个坐标一个。它们是unity_4LightPosX0、unity_4LightPosY0和unity_4LightPosZ0,它们在 UnityShaderVariables 中定义。这些变量的第一个分量包含第一个顶点光源的位置。

void ComputeVertexLightColor (inout Interpolators i) {
	#if defined(VERTEXLIGHT_ON)
		float3 lightPos = float3(
			unity_4LightPosX0.x, unity_4LightPosY0.x, unity_4LightPosZ0.x
		);
		i.vertexLightColor = unity_LightColor[0].rgb;
	#endif
}

接下来,我们计算光矢量,光方向和ndol因子。我们不能在这里使用UNITY_LIGHT_ATTENUATION宏,所以让我们再次使用1/1+d2。这导致最终的颜色。

void ComputeVertexLightColor (inout Interpolators i) {
	#if defined(VERTEXLIGHT_ON)
		float3 lightPos = float3(
			unity_4LightPosX0.x, unity_4LightPosY0.x, unity_4LightPosZ0.x
		);
		float3 lightVec = lightPos - i.worldPos;
		float3 lightDir = normalize(lightVec);
		float ndotl = DotClamped(i.normal, lightDir);
		float attenuation = 1 / (1 + dot(lightVec, lightVec));
		i.vertexLightColor = unity_LightColor[0].rgb * ndotl * attenuation;
	#endif
}

请注意,这只是一个分散的术语。虽然我们也可以计算镜面反射项,但在大三角形上插值时,它看起来会非常糟糕。 实际上,UnityShaderVariables提供了另一个变量,unity_4LightAtten0。它包含有助于近似像素光源衰减的因素。使用这个,我们的衰减变为1/1+d2a。

float attenuation = 1 /(1 + dot(lightVec, lightVec) * unity_4LightAtten0.x);

7.2 Four Vertex Lights

要包含 Unity 支持的所有四个顶点光源,我们必须执行相同的顶点光源计算四次,并将结果相加。我们无需自己编写所有代码,而是可以使用 UnityCG 中定义的 Shade4PointLights 函数。我们必须向它提供位置矢量,光色,衰减因子,以及顶点位置和法线。

void ComputeVertexLightColor (inout Interpolators i) {
	#if defined(VERTEXLIGHT_ON)
		i.vertexLightColor = Shade4PointLights(
			unity_4LightPosX0, unity_4LightPosY0, unity_4LightPosZ0,
			unity_LightColor[0].rgb, unity_LightColor[1].rgb,
			unity_LightColor[2].rgb, unity_LightColor[3].rgb,
			unity_4LightAtten0, i.worldPos, i.normal
		);
	#endif
}

What does Shade4PointLights look like?

这实际上只是我们执行的相同计算,四次。操作顺序略有不同。归一化是在点积之后,使用 rsqrt 执行的。该函数计算倒数平方根。

// Used in ForwardBase pass: Calculates diffuse lighting
// from 4 point lights, with data packed in a special way.
float3 Shade4PointLights (
	float4 lightPosX, float4 lightPosY, float4 lightPosZ,
	float3 lightColor0, float3 lightColor1,
	float3 lightColor2, float3 lightColor3,
	float4 lightAttenSq, float3 pos, float3 normal) {
	// to light vectors
	float4 toLightX = lightPosX - pos.x;
	float4 toLightY = lightPosY - pos.y;
	float4 toLightZ = lightPosZ - pos.z;
	// squared lengths
	float4 lengthSq = 0;
	lengthSq += toLightX * toLightX;
	lengthSq += toLightY * toLightY;
	lengthSq += toLightZ * toLightZ;
	// NdotL
	float4 ndotl = 0;
	ndotl += toLightX * normal.x;
	ndotl += toLightY * normal.y;
	ndotl += toLightZ * normal.z;
	// correct NdotL
	float4 corr = rsqrt(lengthSq);
	ndotl = max(float4(0,0,0,0), ndotl * corr);
	// attenuation
	float4 atten = 1.0 / (1.0 + lengthSq * lightAttenSq);
	float4 diff = ndotl * atten;
	// final color
	float3 col = 0;
	col += lightColor0 * diff.x;
	col += lightColor1 * diff.y;
	col += lightColor2 * diff.z;
	col += lightColor3 * diff.w;
	return col;
}

现在,如果对象最终的光源数超过像素光源数,则最多将包含四个光源作为顶点光源。实际上,Unity 试图通过将一个光源同时包含为像素和顶点光源来隐藏像素光源和顶点光源之间的过渡。该光被包含两次,其顶点和像素版本的强度不同。

What happens when there are less than four vertex lights?

您仍会计算四个顶点光源。其中一些只是黑色的。所以你总是付出四盏灯的代价。

  默认情况下,Unity 决定哪些光源成为像素光源。您可以通过更改光源的渲染模式来覆盖此设置。无论限制如何,重要光源始终呈现为像素光源。不重要的光源永远不会渲染为像素光源。

八.Spherical Harmonics

当我们用完所有像素光源和所有顶点光源时,我们可以回退到另一种渲染光源的方法。我们可以使用球面谐波。所有三种光源类型都支持此功能。 球谐波背后的想法是,你可以用一个函数来描述某个点的所有入射光。

  此函数在球体的表面上定义。 通常,此函数使用球面坐标进行描述。但您也可以使用 3D 坐标。这允许我们使用对象的法向量对函数进行采样。 要创建这样的函数,您必须对所有方向的光强度进行采样,然后弄清楚如何将其转换为单个连续函数。为了完美,您必须对每个物体表面上的每个点执行此操作。这当然是不可能的。我们必须用一个近似值来就足够了。

  首先,我们仅从对象的本地来源的角度定义函数。这适用于沿物体表面变化不大的照明条件。对于小物体以及弱或远离物体的灯光,情况确实如此。幸运的是,对于不符合像素或顶点光源状态的光源,通常就是这种情况。

   其次,我们还必须近似函数本身。您可以将任何连续函数分解为不同频率的多个函数。这些被称为波段。对于任意函数,您可能需要无限数量的波段来执行此操作。 一个简单的例子是组成正弦波。从基本正弦波开始。

此示例使用具有固定模式的常规正弦波。要用正弦波描述任意函数,您必须调整每个波段的频率,振幅和偏移,直到获得完美匹配。 如果您使用的波段少于完美匹配所需的波段,则最终会得到原始函数的近似值。

  使用的波段越少,近似值的精确度就越低。这种技术用于压缩很多东西,比如声音和图像数据。在我们的例子中,我们将用它来近似3D照明。 频率最低的频带与函数的大特征相对应。我们绝对希望保留这些。

  因此,我们将丢弃频率较高的频段。这意味着我们失去了照明功能的细节。如果照明变化不快,这很好,所以我们将不得不再次将自己限制为仅漫射光。

8.1 Spherical Harmonics Bands

照明最简单的近似值是均匀的颜色。照明在所有方向上都是相同的。这是第一个波段,我们将标识为 Y00。它由单个子函数定义,该子函数只是一个常量值。 第二波段引入线性定向光。对于每个轴,它描述了大部分光线的来源。因此,它分为三个函数,用Y1-1,Y10和Y11标识。每个函数都包含我们法线的一个坐标,乘以一个常量。 第三个波段变得更加复杂。它由五个函数组成Y2-2.....Y22这些函数是二次的,这意味着它们包含我们法线坐标的两个乘积。 我们可以继续前进,但Unity只使用前三个频段。它们就在一张桌子上。所有项应乘以 1 /2√π。

这实际上是一个单一的函数,拆分,以便您可以识别其子函数。最终结果是将所有九个术语相加。通过调制九个项中的每一个项以及一个附加因子来创建不同的照明条件。

What determines the shape of this function?

因此,我们可以用九个因子来表示任何照明条件的近似值。由于这些是RGB颜色,我们最终得到27个数字。我们也可以把函数的常量部分合并到这些因子中。这导致了我们的最终功能,

您可以可视化法线坐标,以了解term表示的方向。例如,下面是将正坐标着色为白色和将负坐标着色为红色的方法。

float t = i.normal.x;
return t > 0 ? t : float4(1, 0, 0, 1) * -t;

然后,您可以使用 i.normal.x 和 i.normal.x * i.normal.y 等来可视化每个term。

8.2Using Spherical Harmonics

每个由球面谐波近似的光都必须被分解为27个数字。幸运的是,Unity可以非常快速地做到这一点。基本通道可以通过 UnityShaderVariables 中定义的一组七个 float4 变量来访问它们。 UnityCG 包含 ShadeSH9 函数,该函数根据球面谐波数据和法向参数计算照明。它需要一个 float4 参数,其第四个分量设置为 1。

What does ShadeSH9 look like?

该函数使用两个子函数,一个用于拳头两个波段,另一个用于第三个波段。这样做是因为 Unity 的着色器可以在顶点程序和片段程序之间拆分计算。这是我们将来要考虑的优化。 此外,球面谐波计算在线性空间中进行。ShadeSH9 函数在需要时将结果转换为 gamma 空间。

// normal should be normalized, w=1.0
half3 SHEvalLinearL0L1 (half4 normal) {
	half3 x;

	// Linear (L1) + constant (L0) polynomial terms
	x.r = dot(unity_SHAr,normal);
	x.g = dot(unity_SHAg,normal);
	x.b = dot(unity_SHAb,normal);

	return x;
}

// normal should be normalized, w=1.0
half3 SHEvalLinearL2 (half4 normal) {
	half3 x1, x2;
	// 4 of the quadratic (L2) polynomials
	half4 vB = normal.xyzz * normal.yzzx;
	x1.r = dot(unity_SHBr,vB);
	x1.g = dot(unity_SHBg,vB);
	x1.b = dot(unity_SHBb,vB);

	// Final (5th) quadratic (L2) polynomial
	half vC = normal.x * normal.x - normal.y * normal.y;
	x2 = unity_SHC.rgb * vC;

	return x1 + x2;
}

// normal should be normalized, w=1.0
// output in active color space
half3 ShadeSH9 (half4 normal) {
	// Linear + constant polynomial terms
	half3 res = SHEvalLinearL0L1(normal);

	// Quadratic polynomials
	res += SHEvalLinearL2(normal);

	if (IsGammaSpace())
		res = LinearToGammaSpace(res);

	return res;
}

要很好地查看最终的近似值,请直接返回片段程序中 ShadeSH9 的结果。

float3 shColor = ShadeSH9(float4(i.normal, 1));
	return float4(shColor, 1);

	return UNITY_BRDF_PBS(
		albedo, specularTint,
		oneMinusReflectivity, _Smoothness,
		i.normal, viewDir,
		CreateLight(i), CreateIndirectLight(i)
	);

我们的物体不再是黑色的。他们已经拾起了环境色。Unity 使用球面谐波将场景的环境色添加到对象。 现在激活一堆灯。确保有足够的像素和顶点光源,以便所有像素和顶点光源都用完。其余的被添加到球面谐波中。同样,Unity 将拆分光源以混合过渡。

就像使用顶点光源一样,我们将球面谐波光数据添加到漫射间接光中。另外,让我们确保它永远不会产生任何负面因素。毕竟,这是一个近似值。

UnityIndirect CreateIndirectLight (Interpolators i) {
	UnityIndirect indirectLight;
	indirectLight.diffuse = 0;
	indirectLight.specular = 0;

	#if defined(VERTEXLIGHT_ON)
		indirectLight.diffuse = i.vertexLightColor;
	#endif

	indirectLight.diffuse += max(0, ShadeSH9(float4(i.normal, 1)));
	
	return indirectLight;
}

float4 MyFragmentProgram (Interpolators i) : SV_TARGET {
	…

//	float3 shColor = ShadeSH9(float4(i.normal, 1));
//	return float4(shColor, 1);

	return UNITY_BRDF_PBS(
		albedo, specularTint,
		oneMinusReflectivity, _Smoothness,
		i.normal, viewDir,
		CreateLight(i), CreateIndirectLight(i)
	);
}

但是我们只能在基本通道中执行此操作。由于球面谐波与顶点光源无关,因此我们不能依赖相同的关键字。相反,我们将检查是否定义了FORWARD_BASE_PASS。

#if defined(FORWARD_BASE_PASS)
		indirectLight.diffuse += max(0, ShadeSH9(float4(i.normal, 1)));
	#endif

这再次消除了球面谐波,因为FORWARD_BASE_PASS没有定义。如果将像素光计数设置为零,则只有顶点光源可见。

在包含“My Lighting”之前,在基本通道中定义FORWARD_BASE_PASS。现在,我们的代码知道我们何时处于基本通道中。

Pass {
			Tags {
				"LightMode" = "ForwardBase"
			}

			CGPROGRAM

			#pragma target 3.0

			#pragma multi_compile _ VERTEXLIGHT_ON

			#pragma vertex MyVertexProgram
			#pragma fragment MyFragmentProgram

			#define FORWARD_BASE_PASS

			#include "My Lighting.cginc"

			ENDCG
		}

我们的着色器最终包括顶点光源和球面谐波。如果您确保像素光数大于零,您将看到所有三种照明方法的组合。

8.3 SkyBox

如果球面谐波包括纯色环境色,它也可以与环境天空盒一起使用吗?是的!Unity也将用球面谐波近似该天空盒。要尝试一下,请关闭所有灯光,然后选择环境照明的默认天空盒。默认情况下,新场景使用此天空盒,但我们在前面的教程中将其删除。

Unity 现在在后台渲染天空盒。它是一个程序生成的天空盒,基于主定向光。由于我们没有活跃的光,它的行为就像太阳坐在地平线上一样。您可以看到对象拾取了天空盒的一些颜色,这导致了一些微妙的阴影。这都是通过球面谐波完成的。 打开主方向灯。这将大大改变天空盒。

  您可能能够注意到球面谐波的变化比天空盒晚一点。这是因为Unity需要一些时间来近似天空盒。只有当它突然改变时,这才真正明显。

物体突然变得明亮了很多!环境贡献非常强。程序性天空盒代表了一个完美的晴天。在这些条件下,完全白色的表面确实会显得非常明亮。在伽马空间中渲染时,这种效果将最强。在现实生活中,没有多少完美的白色表面,它们通常要暗得多。

posted @ 2022-06-15 09:36  Naxts  阅读(329)  评论(0编辑  收藏  举报