本文是自定义可渲染管线系列比较重要的章节,我们将实现自定义可编程渲染管线对灯光照明的支持。
如果我们想创建一个更加逼真的场景,那么我们必须要模拟物体表面的光照现象。这需要提供更加复杂的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。
法线插值
虽然法向量在顶点程序中是单位长度的,但三角形间的线性插值会影响它们的长度。我们可以通过渲染向量长度与1之间的差值来可视化误差,并将其放大10倍以使其更加明显。
base.rgb = abs(length(input.normalWS) - 1.0) * 10.0;
我们可以通过对LitPassFragment中的法向量进行归一化来平滑插值失真。当只观察法向量时,这种差异并不是很明显,但当用于照明时,这种差异就更明显了。
base.rgb = normalize(input.normalWS);
表面参数
着色器中的照明是实质模拟光线到物体表面的相互作用,这意味着我们必须跟踪表面的属性。现在我们有一个法向量和一个底色。我们可以将后者分为两部分: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);
光源
要进行光照计算,我们还需要知道光的特性。在本教程中,我们将只讨论平行光。平行光表示光源离得很远,所以它的位置无关紧要,只与它的方向有关。它可以模拟地球上的太阳光和其它单向入射光。
这里我们将使用一个结构体来存储光源数据,现在我们只需要一个颜色和一个方向就足够了。把它放在一个单独的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还会找出哪些光源会影响相机可见的空间。 我们可以依靠这些信息而不是全局的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;
}
现在,我们的着色器最多支持四个平行光。 通常只需要一个平行光来表示太阳或月球,但是也可能存在行星上有多个太阳的场景。 定向灯也可以用于近似多个大型照明设备,例如大型体育场的照明设备。
如果游戏始终只有一个平行光,那么你可以摆脱循环,或者制作多个着色器变体。 但是对于本教程,我们为了简单坚持一个通用循环。 最好的性能总是通过剔除不需要的内容来实现的。
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