Directional Lights(平行光、方向光)
——直接照明
本节内容
- 使用法向量来计算光照
- 支持多达四个平行光
- 应用双向反射分布函数(BRDF)
- 制作有照明的透明材质
- 创建一个自定义的着色器图形用户界面(Shader GUI)。
这是一个关于如何创建一个Custom SRP的系列教程的第三个部分,它添加了对多个平行光的支持。
这个教程使用的是Unity版本是2019.2.6f1.
(ps:文章总被吞…最后偶然看到可能会被吞的一些词儿…尝试改了点但有些意思感觉不到位~)
1. 照明(Lighting)
如果我们想要创建一个更真实的场景,那么我们必须模拟光如何与物体的表面相互作用。这需要一个比我们目前的无光照的shader更复杂的shader。
1.1 照明着色器(Lit Shader)
复制UnlitPass.hlsl
文件并将其重命名为LitPass.hlsl
。调整引用保护定义以及顶点和片元函数名。稍后我们将添加光照计算。
#ifndef CUSTOM_LIT_PASS_INCLUDED
#define CUSTOM_LIT_PASS_INCLUDED
…
Varyings LitPassVertex (Attributes input) { … }
float4 LitPassFragment (Varyings input) : SV_TARGET { … }
#endif
也复制Unlit
着色器,并将其重命名为Lit
。更改其菜单名称、引用的文件、以及使用的函数。让我们同样也改变默认颜色为灰色,因为一个完全白色的表面在一个明亮的场景中会显得非常明亮。URP默认也使用灰色。
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
}
}
}
我们将使用一个自定义照明方法,通过设置shader的照明模式为CustomLit
。在Pass中添加一个Tags
块,包含"LightMode" = "CustomLit"
。
Pass {
Tags {
"LightMode" = "CustomLit"
}
…
}
要渲染使用这个pass的对象,我们必须在CameraRenderer
中包含它。首先为它添加一个shader标签标识符。
static ShaderTagId
unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit"),
litShaderTagId = new ShaderTagId("CustomLit");
然后将它添加到要在DrawVisibleGeometry
中渲染的pass中,就像我们在DrawUnsupportedShaders
中做的那样。
var drawingSettings = new DrawingSettings(
unlitShaderTagId, sortingSettings
) {
enableDynamicBatching = useDynamicBatching,
enableInstancing = useGPUInstancing
};
drawingSettings.SetShaderPassName(1, litShaderTagId);
现在我们可以创建一个新的非透明的材质,尽管目前它产生的结果与无光照的材质相同。
1.2 法向量(Normal Vectors)
一个物体被照亮的程度取决于多种因素,包括光与物体表面之间的相对角度。为了知道表面的方向,我们需要访问表面的法线,它是一个垂直于表面的单位长度的向量。这个向量是顶点数据的一部分,在对象空间中定义,就像位置一样。 所以把它添加到LitPass
的Attributes
中。
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.hlsl
的TransformObjectToWorldNormal
方法在LitPassVertex
中将法线变换到世界空间中。
output.positionWS = TransformObjectToWorld(input.positionOS);
output.positionCS = TransformWorldToHClip(positionWS);
output.normalWS = TransformObjectToWorldNormal(input.normalOS);
TransformObjectToWorldNormal 是如何工作的?
.
检查代码时,您将看到它使用了两种方法之一,这取决于是否定义了 UNITY_ASSUME_UNIFORM_SCALING。
.
当 UNITY_ASSUME_UNIFORM_SCALING被定义时,它调用 TransformObjectToWorldDir,这和 TransformObjectToWorld做的是一样的,除了它忽略了平移部分,因为我们处理的是方向向量而不是位置。但是这个向量也会被均匀缩放,所以之后应该被归一化。
·
在另一种情况下,不假设是均匀缩放。这是更复杂的,因为当一个物体因非均匀缩放而变形时,法向量必须反向缩放以匹配新的表面方向。这需要与转置的 UNITY_MATRIX_I_M矩阵相乘,并进行归一化。
·
使用 UNITY_ASSUME_UNIFORM_SCALING是一个轻微的优化,你可以通过自己定义它来启用。然而,当使用 GPU-Instancing时,这将更有意义。因为 UNITY_MATRIX_I_M矩阵数组必须发送给GPU,在不需要的时候避免这样做是值得的。你可以通过在着色器中添加 #pragma instancing_options assumeuniformscaling指令来启用它,但只有在你用统一缩放渲染对象时才这么做。
为了验证我们是否在LitPassFragment
中得到了正确的法向量,我们可以使用它作为颜色输出。
base.rgb = input.normalWS;
return base;
负值无法显示,所以它们被固定为零。
1.3 差值法线(Interpolated Normals)
虽然在顶点程序中,法向量是单位长度的,但三角形之间的线性插值会影响它们的长度。我们可以通过渲染1和向量的长度之间的差值来可视化误差,并将结果放大10倍,使其更明显。
base.rgb = abs(length(input.normalWS) - 1.0) * 10.0;
我们可以通过对LitPassFragment
中的法向量进行归一化来平滑插值失真。当只看法向量时,这种差异并不明显,但当用于照明时,这种差异就更明显了。
base.rgb = normalize(input.normalWS);
1.4 表面属性(Surface Properties)
在一个shader中产生照明需要模拟光照之间的交互作用,这意味着我们必须跟踪表面的属性。现在我们有一个法向量和一个基色。我们可以将后者分成两部分:RGB颜色和Alpha值。我们将在一些不同的地方使用这些数据,所以让我们定义一个方便的Surface
结构体来包含所有相关数据。把这个结构体放在ShaderLibrary
文件夹中的一个单独的Surface.hlsl
文件中。
#ifndef CUSTOM_SURFACE_INCLUDED
#define CUSTOM_SURFACE_INCLUDED
struct Surface {
float3 normal;
float3 color;
float alpha;
};
#endif
我们不应该把法线定义为 normalWS 吗?
.
可以,但是表面不关心法线是在什么空间定义的。光照计算可以在任何合适的3D空间中进行,所以我们不为法线定义这个空间限制。当填充数据时,我们只需要在所有地方使用相同的空间。我们将使用世界空间,但我们之后有可能会切换到另一个空间,一切仍将保持不变。
在LitPass
中Common
之后引用它,这样我们就可以保持LitPass的简洁。从现在起,我们将把专用的代码放在它们自己的HLSL文件中,以便更容易地定位相关的功能。
#include "../ShaderLibrary/Common.hlsl"
#include "../ShaderLibrary/Surface.hlsl"
在LitPassFragment
中定义一个surface
变量并填充它,结果变成表面的颜色和透明度。
Surface surface;
surface.normal = normalize(input.normalWS);
surface.color = base.rgb;
surface.alpha = base.a;
return float4(surface.color, surface.alpha);
这不是低效的代码吗?
.
这没有区别,因为着色器编译器将生成高度优化的程序,完全重写了我们的代码。结构体纯粹是为了方便我们使用。你可以通过在着色器面板的 Compile and show code按钮检查编译器的编译结果。
1.5 光照计算(Calculating Lighting)
为了计算实际的光照,我们将创建一个具有Surface
参数的GetLighting
函数。最初让它返回表面法线的Y分量。因为这是照明功能,我们将把它放在一个单独的Lighting.hlsl
文件中。
#ifndef CUSTOM_LIGHTING_INCLUDED
#define CUSTOM_LIGHTING_INCLUDED
float3 GetLighting (Surface surface) {
return surface.normal.y;
}
#endif
在LitPass
中引用Surface
之后引用它,因为照明依赖于Surface.hlsl
。
#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Lighting.hlsl"
为什么不在 Lighting.hlsl 中 引用 Surface.hlsl?
.
我们可以这样做,但最终的结果可能是多个文件依赖于多个其他文件,依赖关系会十分杂乱。相反,我选择将所有include语句放在一个地方,这样可以明确依赖关系。这也使得用一个文件替换另一个文件从而改变着色器的工作方式变得更加容易,只要新文件定义了其他文件依赖的相同功能。
现在我们可以在LitPassFragment
中获得照明,并将其用于片元函数返回颜色的RGB部分。
float3 color = GetLighting(surface);
return float4(color, surface.alpha);
现在,输出的是表面法线的Y分量,所以它在球体的顶部是1,在它的两侧是0。再往下结果变为负值,并在底部达到- 1。但我们观察不到负值,它等于法向量和上(up
)向量夹角的余弦值。忽略负的部分,这在视觉上就好像一个漫反射的平行光从上垂直的向下照明。最后一步是在GetLighting
中把表面颜色合并到结果中,将其诠释为表面反照率(Albedo
)。
float3 GetLighting (Surface surface) {
return surface.normal.y * surface.color;
}
反照率(Albedo)是什么意思?
.
反照率在拉丁语中是白色程度的意思。它代表着光被一个表面漫反射的程度。如果反照率不是全白,那么意味着部分光能被吸收而不是被反射。
2. 灯光(Lights)
为了表现合适的照明,我们还需要知道光源的属性。在本章节中,我们将只使用平行光。平行光代表着一个距离很远很远的光源,它的位置并不重要,重要的是它的方向。这是一种简化,但它足以模拟地球上的太阳光和其他单向光线的情况。
2.1 光照结构(Light Structure)
我们将使用一个结构体来存储光的数据。现在我们只需要一个颜色和一个方向就够了。将其放在单独的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.hlsl
之前引用它。
#include "../ShaderLibrary/Light.hlsl"
#include "../ShaderLibrary/Lighting.hlsl"
2.2 照明函数(Lighting Functions)
在Lighting
中添加一个IncomingLight
函数,计算在给定一个表面和光源后有多少入射光。对于任意方向的光,我们必须取表面法线和光方向的点积。我们可以用点积函数。结果应该被光的颜色调节。
float3 IncomingLight (Surface surface, Light light) {
return dot(surface.normal, light.direction) * light.color;
}
什么是点积?
.
两个向量的点积在几何学上的定义如下图:
这意味着它是两个向量夹角的余弦值,乘以它们的长度。 对于两个单位长度的向量 A·B=cos θ
在代数中,他被如下图这样定义:
·
可以通过将所有的分量相乘并相加来计算他们的值:
·
将点积具象化的话,这个操作是直接将一个矢量投射到另一个矢量上,就像在它投下阴影一样。通过这样做,你最终得到一个直角三角形,其底边的长度正是点积的结果。如果两个向量都是单位长度,那点积的结果就是它们夹角的余弦值。
·
但这只在表面朝向光线时才正确。 当点积为负时,我们需要将它限制到零,这可以通过saturate
函数来实现。
float3 IncomingLight (Surface surface, Light light) {
return saturate(dot(surface.normal, light.direction)) * light.color;
}
saturate 做了什么?fffffffff
·
它将一个值限制于0和1之间。我们只需要指定一个最小值,因为点积永远不应该大于1。 saturate是一个非常常见的着色器操作。
添加另一个GetLighting
函数,它返回表面和光源的最终照明。现在它返回的是入射光结果乘以表面颜色。在其他函数上面定义这个函数。
float3 GetLighting (Surface surface, Light light) {
return IncomingLight(surface, light) * surface.color;
}
最后,调整GetLighting
函数,它只有一个表面参数,所以它需要调用另一个同名函数来获取一个光源信息,使用GetDirectionalLight
来提供平行光数据。
float3 GetLighting (Surface surface) {
return GetLighting(surface, GetDirectionalLight());
}
2.3 向GPU发送光源数据(Sending Light Data to the GPU)
我们应该使用当前场景的光,而不是总是使用之前定义的白色的光。默认场景有一个平行光,代表太阳,它是淡黄色的——使用了十六进制
的值fff4d6,并围绕X轴旋转50°,围绕Y轴旋转30°。如果这样的光不存在,那就创建一个。
为了在shader中访问光的数据,我们必须为它定义一个值,就像着色器属性一样。在这个例子中,我们将定义两个float3类型的向量:_DirectionalLightColor
和_DirectionalLightDirection
。把它们放在一个定义在顶部的_CustomLight
缓冲区中。
CBUFFER_START(_CustomLight)
float3 _DirectionalLightColor;
float3 _DirectionalLightDirection;
CBUFFER_END
在GetDirectionalLight
中使用这些值而不是常量。
Light GetDirectionalLight () {
Light light;
light.color = _DirectionalLightColor;
light.direction = _DirectionalLightDirection;
return light;
}
现在我们的渲染管线必须把光数据发送到GPU。我们将为此创建一个新的Lighting.cs
类。它的工作原理类似于CameraRenderer
,但是用于照明。给它一个带有context
参数的公共方法Setup
,在这个方法中它调用一个单独的SetupDirectionalLight
方法。尽管不是严格必要的,但让我们也为它提供一个专用的命令缓冲区,以便在完成时执行,这样也便于调试。另一种方法是可以添加一个缓冲区参数。
using UnityEngine;
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);
}
SetGlobalVector 不是需要 Vector4 类型的参数吗?
·
是的,发送给GPU的向量总是有四个分量,即使我们以较少的分量定义它们,其余的分量会在shader中被隐式掩盖。同样,也有一个从Vector3到Vector4的隐式转换。
光的color
属性是它的配置颜色,但光也有一个单独的强度因子。最后的颜色需要两者相乘。
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();
}
2.4 可见光(Visible Lights)
在进行裁剪时,Unity还会计算出哪些灯光会影响到对相机可见的空间。我们可以依靠这些信息,而不是单一的全局太阳光。为此,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模板类结构,元素类型为VisibleLight。
using Unity.Collections;
using UnityEngine;
using UnityEngine.Rendering;
public class Lighting {
…
void SetupLights () {
NativeArray visibleLights = cullingResults.visibleLights;
}
…
}
什么是 NativeArray?
·
它是一个作用类似于数组的结构,但提供了到本机内存缓冲区的链接。这使得在托管的c#代码和原生的Unity引擎代码之间高效地共享数据成为可能。
2.5 多个平行光(Multiple Directional Lights)
使用可见光数据可以支持多个平行光,但我们必须将所有这些光的数据发送到GPU。所以我们不再使用仅仅一对向量,而是使用两个Vector4
数组存储光的方向和颜色,另外加上一个整数作为光的计数。我们还将定义最大数量的平行光,我们可以使用它初始化两个数组字段来缓冲数据。让我们将最大值设置为4,这对大多数场景来说应该足够了。
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];
为什么不使用结构化的缓冲区?
·
也许可能吧,但我不准备用,因为着色器对结构化的缓冲区的支持还不够好。它们要么根本不被支持或只在片元函数中存在,要么比常规数组性能更差。好消息是,数据在CPU和GPU之间如何传递的细节只影响少数地方,所以很容易改变。这是使用Light结构体的另一个好处。
为SetupDirectionalLight
添加一个索引和一个VisibleLight
参数。让它根据提供的索引设置颜色和方向数据。在这种情况下,最终的颜色是通过调用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
。然后调用buffer
的SetGlobalInt
和SetGlobalVectorArray
将数据发送给GPU。
NativeArray 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
,因为那样会复制它。我们可以使用Unity用于ScriptableRenderContext.DrawRenderers
方法的技巧,通过ref
传递参数。
SetupDirectionalLight(dirLightCount++, ref visibleLight);
这也要求我们将参数定义为引用。
void SetupDirectionalLight (int index, ref VisibleLight visibleLight) { … }
2.6 着色器循环(Shader Loop)
在Light.hlsl
中调整_CustomLight
缓冲区,使其匹配我们的新数据。在本例中,我们将显式地使用float4作为数组类型。数组在着色器中需要有固定的大小,它们不能被调整大小。确保使用与我们在Lighting.cs
中定义的相同的最大值。
#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;
}
rgb 和 xyz 之间有什么区别吗?
·
他们都是语义的别名,使用 rgba和 xyzw是等价的。(译注:但通常来讲,我们使用 rgba 获取颜色,用 xyzw 获取向量和矢量等,便于功能上的区分和理解)
然后调整GetLighting
函数,使用for
循环来分别计算并累计每个平行光对于表面光照的贡献。
float3 GetLighting (Surface surface) {
float3 color = 0.0;
for (int i = 0; i < GetDirectionalLightCount(); i++) {
color += GetLighting(surface, GetDirectionalLight(i));
}
return color;
}
现在我们的shader最多支持四个平行光。 通常只需要一个平行光来代表太阳或月亮,但也许在一个行星上有多个太阳的场景。平行光也可以用于模拟多个大型灯光平台,例如大型体育场的灯光。
如果你的游戏总是有一个单一的方向光,那么你可以摆脱循环,或制作多个shader变体。但在本教程中,我们将保持简单,坚持使用一个通用目的的循环。最好的性能总是需要通过删除不需要的内容来实现,尽管这样做并不总是会产生显著的差异。
2.7 着色器目标等级(Shader Target Level)
具有可变长度的循环曾经是shader的一个问题,但现代GPU可以毫无压力的处理它们,特别是当draw call的所有片元以相同的方式迭代相同的数据。然而,OpenGL ES 2.0和WebGL 1.0的图形api在默认情况下不能处理这样的循环。我们可以通过结合一个硬编码的最大值来让它工作,例如通过GetDirectionalLight
返回min(_DirectionalLightCount, MAX_DIRECTIONAL_LIGHT_COUNT)
,这使得循环成为可能,将其转换为条件代码块序列。
不幸的是,这产生的shader代码是乱糟糟的,性能也下降很多。在非常老式的硬件上,所有的代码块都会被执行,它们是通过条件赋值来控制的。虽然我们可以使它工作,但它使代码更复杂,因为我们还必须进行其他更多的调整。
因此,为了简单起见,我选择忽略这些限制,在项目中取消对WebGL 1.0和OpenGL ES 2.0的支持,它们不支持线性照明。 我们也可以通过#pragma target 3.5
指令,将我们的着色器的目标级别提升到3.5,从而避免为它们编译OpenGL ES 2.0的着色器变体。让我们对两个着色器都这样做。
HLSLPROGRAM
#pragma target 3.5
…
ENDHLSL
3. 双向反射分布函数(BRDF)
我们目前使用的是一个非常简单的光照模型,只适合完美的漫反射表面。我们可以通过应用双向反射分布函数(Bidirectional Reflectance Distribution Function),简称BRDF
,来实现更丰富和真实的照明。有许多类型的BRDF函数。我们将使用URP所使用的相同的方法,这为了性能牺牲了一些更真实的表现。
3.1 入射光(Incoming Light)
当一束光迎面击中表面的片段时,它所有的能量都会影响到片段。为了简单起见,我们假定光束的宽度与表面片段的宽度相匹配。这就是光的方向L
和表面法线N
平行对齐的情况,即 N·L = 1
。当他们没有平行对齐而是至少有一部分的光会偏离表面,所以影响表面的能量也就少了。影响表面的能量是 N·L
。结果为负的话就表明表面远离了光入射的方向,所以它不会被光影响到。
3.2 出射光(Outgoing Light)
我们看不到直接到达表面的光。我们只能看到从表面反射到相机或我们眼睛的那部分。如果表面是一个完美的平面镜,那么光线就会被反射出去,出射角与入射角相等。只有当相机与出射光一致时,我们才能看到这道光。这就是所谓的镜面反射。这是光与表面相互作用的简化,但对于我们当前来说已经足够了。
但如果表面不是完全平坦,光线就会被散射,因为表面的片段实际上是由许多方向不同的小碎片组成的。这将光线分成不同方向的更细小的光束,这有效地模糊了镜面反射。我们最终可能会看到一些散射的光,即使没有与完美的反射方向对齐。
除此之外,光也会穿透表面,在表面细碎的部位反弹,以不同的角度离开,以及还有一些其他我们目前不需要考虑的事情。在极端情况下,我们最终会得到一个完全漫反射的表面,将光线均匀地散射到所有可能的方向。这是我们当前在shader中计算的光照。
无论相机在哪里,表面接收到的漫射光的量是一样的,但这也意味着我们观测到的光能远远小于到达表面碎片的光能,这表明我们应该用另外一些因素来衡量入射光。然而,因为这个因素总是一样的,我们可以把它放入光的颜色和强度中。因此,我们使用的最终光颜色代表了从一个完美的白色漫反射表面片段,接受正面照明时观察到的数量,这只是实际发出的光总量的一小部分。还有其他配置光源的方法,例如指定光通量或辐照度,这样更容易配置真实的光源,但我们目前将坚持现在这种的方法。
3.3 表面属性(Surface Properties)
表面可以是完美的漫反射,或完美的高光反射(镜面反射),或者介于两者之间的任何东西。我们有很多方法可以控制它,目前我们将使用金属工作流,需要添加两个表面属性到Lit
着色器中。
第一个属性用于表明一个表面是金属的还是非金属的,也被称为电介质。因为一个表面可以包含两者的混合,我们将为它添加一个范围0-1的滑动条,1表示它是完全金属的,默认为0。
第二个属性控制表面的光滑程度。我们还将为此使用范围0-1滑动条,0表示完全粗糙,1表示完全光滑。我们将使用0.5作为默认值。
_Metallic ("Metallic", Range(0, 1)) = 0
_Smoothness ("Smoothness", Range(0, 1)) = 0.5
将属性添加到UnityPerMaterial
缓存区中。
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseMap_ST)
UNITY_DEFINE_INSTANCED_PROP(float4, _BaseColor)
UNITY_DEFINE_INSTANCED_PROP(float, _Cutoff)
UNITY_DEFINE_INSTANCED_PROP(float, _Metallic)
UNITY_DEFINE_INSTANCED_PROP(float, _Smoothness)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
还有Surface
结构体。
struct Surface {
float3 normal;
float3 color;
float alpha;
float metallic;
float smoothness;
};
在LitPassFragment
中将它们复制到surface
中。
Surface surface;
surface.normal = normalize(input.normalWS);
surface.color = base.rgb;
surface.alpha = base.a;
surface.metallic = UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Metallic);
surface.smoothness =
UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Smoothness);
并在PerObjectMaterialProperties.cs
中添加对它们的支持。
static int
baseColorId = Shader.PropertyToID("_BaseColor"),
cutoffId = Shader.PropertyToID("_Cutoff"),
metallicId = Shader.PropertyToID("_Metallic"),
smoothnessId = Shader.PropertyToID("_Smoothness");
…
[SerializeField, Range(0f, 1f)]
float alphaCutoff = 0.5f, metallic = 0f, smoothness = 0.5f;
…
void OnValidate () {
…
block.SetFloat(metallicId, metallic);
block.SetFloat(smoothnessId, smoothness);
GetComponent().SetPropertyBlock(block);
}
3.4 BRDF 属性(BRDF Properties)
我们将利用表面属性来计算BRDF方程。它告诉我们有多少光从一个表面反射回来,这是漫反射和高光反射两者的结合。我们需要在漫反射和高光反射部分分割表面颜色,我们还需要知道表面有多粗糙。让我们在一个BRDF
结构体中存放和跟踪这三个值,放在一个单独的BRDF.hlsl
文件中。
#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 = surface.color;
brdf.specular = 0.0;
brdf.roughness = 1.0;
return brdf;
}
在引用Light.hlsl
和Lighting.hlsl
之间引用BRDF.HLSL
#include "../ShaderLibrary/Common.hlsl"
#include "../ShaderLibrary/Surface.hlsl"
#include "../ShaderLibrary/Light.hlsl"
#include "../ShaderLibrary/BRDF.hlsl"
#include "../ShaderLibrary/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;
}
最后,在LitPassFragment
中获取BRDF数据,并将其传递给GetLighting
。
BRDF brdf = GetBRDF(surface);
float3 color = GetLighting(surface, brdf);
3.5 反射率(Reflectivity)
一个表面如何反射是可变的,但一般来说,金属表面通过镜面反射反射所有的光,而其漫反射为零。所以我们声明反射率等于金属表面的属性。被反射的光不会被漫反射,所以我们应该在GetBRDF
中将漫反射的比例调整为1-反射率
。
float oneMinusReflectivity = 1.0 - surface.metallic;
brdf.diffuse = surface.color * oneMinusReflectivity;
在现实中,一些光也会被非金属表面反射,这就给了它们高光。非金属的反射率各不相同,但平均约为0.04
。让我们将其定义为最小反射率,并添加一个OneMinusReflectivity
函数,将0-1
范围调整到0-0.96
。此范围调整与URP使用的方法相同。
#define MIN_REFLECTIVITY 0.04
float OneMinusReflectivity (float metallic) {
float range = 1.0 - MIN_REFLECTIVITY;
return range - metallic * range;
}
在GetBRDF
中使用该函数限制一个最小值。当只渲染漫反射时,这种差异很难被注意到,但当我们添加高光反射后,这种差异会变得很明显。没有它,非金属就不会有高光。
float oneMinusReflectivity = OneMinusReflectivity(surface.metallic);
3.6 高光反射的颜色(Specular Color)
以一种方式反射的光不能以另一种方式反射,这就是所谓的能量守恒,这意味着出射光的能量不能超过入射光的能量,这也表明高光部分的颜色应该等于表面颜色减去漫反射颜色。
brdf.diffuse = surface.color * oneMinusReflectivity;
brdf.specular = surface.color - brdf.diffuse;
然而,这忽略了一个事实,金属会影响高光反射的颜色,而非金属则不会。非金属表面的高光部分的颜色应该是白色的,我们可以通过使用金属度在最小反射率和表面颜色之间进行插值来实现。
brdf.specular = lerp(MIN_REFLECTIVITY, surface.color, surface.metallic);
3.7 粗糙度(Roughness)
粗糙度是光滑度的反义词,所以我们可以简单地用1减去平滑度。Core RP
库中有一个名为PerceptualSmoothnessToPerceptualRoughness
的函数,我们将使用这个函数,来定义光滑度和粗糙度是“感知上的”。我们可以通过PerceptualRoughnessToRoughness
函数转换为实际的粗糙度值,该函数对“感知的值”进行平方,这种方法与迪士尼的光照模型相匹配。之所以这样做,是因为在编辑材质时调整“感知的值”更为直观。
float perceptualRoughness =
PerceptualSmoothnessToPerceptualRoughness(surface.smoothness);
brdf.roughness = PerceptualRoughnessToRoughness(perceptualRoughness);
这些函数在Core RP
库的CommonMaterial.hlsl
文件中定义。在我们自己的Common.hlsl
文件中引用Common.hlsl
之后已用它。
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/CommonMaterial.hlsl"
#include "UnityInput.hlsl"
3.8 视图方向(View Direction)
为了确定相机与完美反射方向的对齐情况,我们需要知道相机的位置。Unity通过float3 _WorldSpaceCameraPos
使这些数据可访问,所以将其添加到UnityInput.hlsl
中。
float3 _WorldSpaceCameraPos;
为了在LitPassFragment
中获得视图方向,即从表面到相机的方向,我们需要在Varyings
中添加表面在世界空间的位置。
struct Varyings {
float4 positionCS : SV_POSITION;
float3 positionWS : VAR_POSITION;
…
};
Varyings LitPassVertex (Attributes input) {
…
output.positionWS = TransformObjectToWorld(input.positionOS);
output.positionCS = TransformWorldToHClip(output.positionWS);
…
}
我们将视图方向视为表面数据的一部分,因此将其添加到Surface中。
struct Surface {
float3 normal;
float3 viewDirection;
float3 color;
float alpha;
float metallic;
float smoothness;
};
在LitPassFragment
中对它赋值,它等于相机坐标减去片元坐标,并进行归一化。
surface.normal = normalize(input.normalWS);
surface.viewDirection = normalize(_WorldSpaceCameraPos - input.positionWS);
3.9 高光强度(Specular Strength)
我们观察到的高光反射的强度取决于我们的视角方向与完美反射方向的匹配程度。我们将使用URP中使用的相同公式,它是极简化的Cook-Torrance BRDF的变体。 这个公式包含一些平方操作,所以让我们先在Common.hlsl
中添加一个方便的Square
函数。
float Square (float v) {
return v * v;
}
然后在BRDF中添加一个带有Surface
、BRDF
数据和Light
参数的SpecularStrength函数,这个函数应该计算如下图的公式:
d
代表饱和度,N
表示表面法线,L
是光的方向,H
表示归一化后的L + V
,是一个光的方向和视图方向中间的向量。使用SafeNormalize
方法让向量归一化,来避免向量相反的情况下除以0,。最后,n = 4r + 2
是一个标准化的项。
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);
}
这个方法是如何工作的?
·
BRDF 理论太复杂了,不能简单地解释清楚,也不是本教程的重点。 您可以查看 URP的 Lighting.hlsl文件获得一些代码文档和参考资料。
接下来,添加一个DirectBRDF
方法,它将返回通过直接照明获得的颜色。给定一个表面、BRDF数据和光数据。结果是高光颜色由高光强度控制,再加上漫反射颜色。
float3 DirectBRDF (Surface surface, BRDF brdf, Light light) {
return SpecularStrength(surface, brdf, light) * brdf.specular + brdf.diffuse;
}
GetLighting
则必须将入射光乘以该函数的输出。
float3 GetLighting (Surface surface, BRDF brdf, Light light) {
return IncomingLight(surface, light) * DirectBRDF(surface, brdf, light);
}
我们现在实现了高光反射反射,它为我们的表面添加了高光部分。对于完全粗糙的表面,高光模拟漫反射。光滑的表面得到更集中的亮光。一个完全光滑的表面会有一个我们看不到的无限小的高光。需要一些散射才能使它可见。
由于能量守恒,对于光滑的表面,因为大部分到达表面的光都聚焦了,高光可以变得非常明亮,因此,我们才会看到更多的光,而不是由于高光部分可见的漫反射。你可以通过大幅缩小最终输出的颜色来验证这一点。
你也可以通过使用白色以外的基色,来验证金属材质会影响高光部分的颜色,而非金属材质不会。
我们现在有了可靠的直接照明的功能,尽管目前的效果还太暗,尤其是对金属材质,因为我们还不支持环境反射等。在这一点上,一个标准的黑色环境会比默认的天空框更真实,但这会让我们的对象更难以观察到。添加更多的灯光也可以做到。
3.10 网格球体(Mesh Ball)
让我们也为MeshBall.cs
添加对不同金属度和平滑度属性的支持。这需要添加两个浮点数组。
static int
baseColorId = Shader.PropertyToID("_BaseColor"),
metallicId = Shader.PropertyToID("_Metallic"),
smoothnessId = Shader.PropertyToID("_Smoothness");
…
float[]
metallic = new float[1023],
smoothness = new float[1023];
…
void Update () {
if (block == null) {
block = new MaterialPropertyBlock();
block.SetVectorArray(baseColorId, baseColors);
block.SetFloatArray(metallicId, metallic);
block.SetFloatArray(smoothnessId, smoothness);
}
Graphics.DrawMeshInstanced(mesh, 0, material, matrices, 1023, block);
}
让我们在Awake
中,把25%的实例设置为金属质感,光滑度在0.05到0.95之间随机。
baseColors[i] =
new Vector4(
Random.value, Random.value, Random.value,
Random.Range(0.5f, 1f)
);
metallic[i] = Random.value < 0.25f ? 1f : 0f;
smoothness[i] = Random.Range(0.05f, 0.95f);
然后让网格球体使用一个照明材质。
4. 透明(Transparency)
让我们再次考虑透明度。物体仍然会根据它们的alpha值变淡,但现在是反射光也变淡了。这对于漫反射是有意义的,因为只有一部分光被反射,而其余的光穿过表面。
然而,高光反射也会减弱。在全透的玻璃情况下,光要么通过,要么被反射。高光反射部分不会减弱。我们目前的方法不能表现出这一点。
4.1 预乘透明度(Premultiplied Alpha)
解决的办法是只淡化漫反射光,同时保持高光反射的全部强度。Src Blend
模式目前不适用于我们的需求,让我们将其设置为One
,而目标混合模式仍然使用OneMinusSrcAlpha
。
这恢复了高光反射的强度,但漫反射没有减弱。我们通过将使用表面透明度来减弱漫反射颜色来解决这个问题。因此我们用透明度对漫反射进行预乘,而不是稍后依靠GPU去混合。这种方法被称为预乘透明度混合。在GetBRDF
里这样做。
brdf.diffuse = surface.color * oneMinusReflectivity;
brdf.diffuse *= surface.alpha;
4.2 预乘开关(Premultiplication Toggle)
将透明度和漫反射预乘有效地把对象变成像玻璃的材质,而一成不变的透明度混合模式使对象总是只存在部分。让我们同时支持两种方法,通过在GetBRDF
中添加一个布尔参数来控制是否对透明度进行预乘,默认设置为false。
BRDF GetBRDF (inout Surface surface, bool applyAlphaToDiffuse = false) {
…
if (applyAlphaToDiffuse) {
brdf.diffuse *= surface.alpha;
}
…
}
我们可以使用一个_PREMULTIPLY_ALPHA
关键字来决定在LitPassFragment
中使用哪种方法,类似于我们之前如何控制透明度裁剪。
#if defined(_PREMULTIPLY_ALPHA)
BRDF brdf = GetBRDF(surface, true);
#else
BRDF brdf = GetBRDF(surface);
#endif
float3 color = GetLighting(surface, brdf);
return float4(color, surface.alpha);
为Lit
的Pass添加一个着色器特性(shader feature
)。
#pragma shader_feature _CLIPPING
#pragma shader_feature _PREMULTIPLY_ALPHA
并添加一个开关属性。
[Toggle(_PREMULTIPLY_ALPHA)] _PremulAlpha ("Premultiply Alpha", Float) = 0
5. 着色器的图形用户界面(Shader GUI)
我们现在支持多种渲染模式,每种模式都需要特定的设置。为了使模式之间的切换更容易,让我们在材质面板中添加一些按钮来应用预设配置。
5.1 自定义着色器GUI(Custom Shader GUI)
添加一个CustomEditor "CustomShaderGUI"
语句到灯光着色器的主块。
Shader "Custom RP/Lit" {
…
CustomEditor "CustomShaderGUI"
}
这指示Unity编辑器使用CustomShaderGUI
类的一个实例来绘制使用Lit着色器的材质的面板。为该类创建一个脚本资源,并将其放入一个新的Custom RP/Editor
文件夹中。
我们需要使用UnityEditor
、 UnityEngine
和UnityEngine.Rendering
名称空间。这个类必须扩展ShaderGUI并重写公共的OnGUI
方法,它有一个MaterialEditor和一个MaterialProperty数组参数。让它调用基类方法,这样我们就得到了默认的显示面板。
using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;
public class CustomShaderGUI : ShaderGUI {
public override void OnGUI (
MaterialEditor materialEditor, MaterialProperty[] properties
) {
base.OnGUI(materialEditor, properties);
}
}
5.2 设置属性和关键字(Setting Properties and Keywords)
为了完成我们的工作,我们需要访问三个东西,我们将它们存储在字段中。首先是材质编辑器,它是底层的编辑器对象,负责显示和编辑材质。第二个是对正在编辑的材质的引用,我们可以通过编辑器的targets
属性来访问它,它被定义为一个Object数组,因为targets是通用Editor类的一个属性。第三个是可以编辑的属性数组。
MaterialEditor editor;
Object[] materials;
MaterialProperty[] properties;
public override void OnGUI (
MaterialEditor materialEditor, MaterialProperty[] properties
) {
base.OnGUI(materialEditor, properties);
editor = materialEditor;
materials = materialEditor.targets;
this.properties = properties;
}
为什么会有多种材质?
·
使用相同着色器的多个材质可以同时被编辑,就像你可以选择和编辑多个游戏对象一样。
为了设置一个属性,我们首先必须在数组中找到它,为此我们可以使用ShaderGUI
.FindProperty方法,传递给它一个名称和属性数组。然后,我们可以通过给它的floatValue
属性赋值来调整它的值。 用一个字符串名称和一个float值参数将其封装在一个方便的SetProperty
方法中。
void SetProperty (string name, float value) {
FindProperty(name, properties).floatValue = value;
}
设置关键字稍微有点复杂。我们将为此创建一个SetKeyword
方法,带有一个字符串和一个布尔参数,以指示该关键字是应启用还是禁用。我们必须在所有材质上调用EnableKeyword
或DisableKeyword
,给它们传递关键字名称。
void SetKeyword (string keyword, bool enabled) {
if (enabled) {
foreach (Material m in materials) {
m.EnableKeyword(keyword);
}
}
else {
foreach (Material m in materials) {
m.DisableKeyword(keyword);
}
}
}
让我们再创建一个SetProperty
变体,用于同时开关"属性-关键字"。
void SetProperty (string name, string keyword, bool value) {
SetProperty(name, value ? 1f : 0f);
SetKeyword(keyword, value);
}
然后定义一些属性的访问器。
bool Clipping {
set => SetProperty("_Clipping", "_CLIPPING", value);
}
bool PremultiplyAlpha {
set => SetProperty("_PremulAlpha", "_PREMULTIPLY_ALPHA", value);
}
BlendMode SrcBlend {
set => SetProperty("_SrcBlend", (float)value);
}
BlendMode DstBlend {
set => SetProperty("_DstBlend", (float)value);
}
bool ZWrite {
set => SetProperty("_ZWrite", value ? 1f : 0f);
}
最后,渲染队列通过分配所有材质的RenderQueue
属性来设置。
RenderQueue RenderQueue {
set {
foreach (Material m in materials) {
m.renderQueue = (int)value;
}
}
}
5.3 预设按钮(Preset Buttons)
可以通过GUILayout.Button
方法创建按钮。传递给它一个标签,这将是一个预设的名称。 如果该方法返回true,则代表它被按下。在应用预设之前,我们应该向编辑器注册一个撤销步骤,这可以通过调用RegisterPropertyChangeUndo
来完成。 因为这段代码对所有预设都是相同的,所以把它放在PresetButton
方法中。
bool PresetButton (string name) {
if (GUILayout.Button(name)) {
editor.RegisterPropertyChangeUndo(name);
return true;
}
return false;
}
我们将为每个预设创建一个单独的方法,从默认的Opaque
模式开始,在激活时恰当地设置属性。
void OpaquePreset () {
if (PresetButton("Opaque")) {
Clipping = false;
PremultiplyAlpha = false;
SrcBlend = BlendMode.One;
DstBlend = BlendMode.Zero;
ZWrite = true;
RenderQueue = RenderQueue.Geometry;
}
}
第二个预设是Clipping
。
void ClipPreset () {
if (PresetButton("Clip")) {
Clipping = true;
PremultiplyAlpha = false;
SrcBlend = BlendMode.One;
DstBlend = BlendMode.Zero;
ZWrite = true;
RenderQueue = RenderQueue.AlphaTest;
}
}
第三个预设是标准的透明,它会淡出对象,所以我们将其命名为Fade
。
void FadePreset () {
if (PresetButton("Fade")) {
Clipping = false;
PremultiplyAlpha = false;
SrcBlend = BlendMode.SrcAlpha;
DstBlend = BlendMode.OneMinusSrcAlpha;
ZWrite = false;
RenderQueue = RenderQueue.Transparent;
}
}
第四种预设是Fade
的变体,应用了预乘的透明度混合。 我们将它命名为Transparent
。
void TransparentPreset () {
if (PresetButton("Transparent")) {
Clipping = false;
PremultiplyAlpha = true;
SrcBlend = BlendMode.One;
DstBlend = BlendMode.OneMinusSrcAlpha;
ZWrite = false;
RenderQueue = RenderQueue.Transparent;
}
}
在OnGUI
结束时调用预设方法,这样它们就会显示在默认材质面板下面。
public override void OnGUI (
MaterialEditor materialEditor, MaterialProperty[] properties
) {
…
OpaquePreset();
ClipPreset();
FadePreset();
TransparentPreset();
}
预设按钮不会经常使用,所以让我们把它们放在一个折叠标签中。这是通过调用[EditorGUILayout](http://docs.unity3d.com/Documentation/ScriptReference/EditorGUILayout.html).Foldout
来完成的。它返回新的折叠状态,我们应该将其存储在一个字段中,只有当折叠打开时才绘制按钮。
bool showPresets;
…
public override void OnGUI (
MaterialEditor materialEditor, MaterialProperty[] properties
) {
…
EditorGUILayout.Space();
showPresets = EditorGUILayout.Foldout(showPresets, "Presets", true);
if (showPresets) {
OpaquePreset();
ClipPreset();
FadePreset();
TransparentPreset();
}
}
5.4 无光照着色器的预设(Presets for Unlit)
我们也可以为我们的无光照着色器使用自定义着色器GUI。
Shader "Custom RP/Unlit" {
…
CustomEditor "CustomShaderGUI"
}
然而,激活预设将导致一个错误,因为我们试图设置一个着色器没有的属性。我们可以通过调整SetProperty
来防止这种情况。 让它调用FindProperty
,并将false作为附加参数,表示如果没有找到该属性,它不应该报告错误。结果将为null,所以只有在不为null的情况下才设置值。
bool SetProperty (string name, float value) {
MaterialProperty property = FindProperty(name, properties, false);
if (property != null) {
property.floatValue = value;
return true;
}
return false;
}
然后调整SetProperty
,使它只在相关属性存在的情况下设置关键字。
void SetProperty (string name, string keyword, bool value) {
if (SetProperty(name, value ? 1f : 0f)) {
SetKeyword(keyword, value);
}
}
5.5 非透明(No Transparency)
现在预设也适用于使用无光照着色器的材质,尽管透明模式在这种情况下没有多大意义,因为相关属性不存在。 让我们在不相关的时候隐藏这个预设。
首先,添加一个返回属性是否存在的HasProperty
方法。
bool HasProperty (string name) =>
FindProperty(name, properties, false) != null;
其次,创建一个方便的属性来检查_PremultiplyAlpha
是否存在。
bool HasPremultiplyAlpha => HasProperty("_PremulAlpha");
最后,通过在TransparentPreset
中检查,使所有的Transparent
相关预设都以该属性为条件。
if (HasPremultiplyAlpha && PresetButton("Transparent")) { … }