使用LOD组
在LOD层级间实现交叉淡入淡出
通过采样反射探针实现环境反射
支持可选的非涅尔反射
这是关于创建自定义可编程渲染管线的教程系列第七部分。内容涵盖细节层级(LOD)系统和简单反射效果,这些功能可以为我们的场景增添细节表现。
本教程基于Unity 2019.2.21f1版本创建,并已升级至2022.3.5f1版本

A bunch of LOD groups and reflection probes 一组LOD组和反射探针
1. LOD Groups
许多小型物体能为场景增添细节并使其更加生动。然而,当细节过小无法覆盖多个像素时,就会退化为难以辨认的噪点。在这种视觉尺度下,最好停止渲染这些物体,这样也能释放CPU和GPU资源来渲染更重要的内容。我们还可以在物体尚可辨识时就提前剔除它们,这能进一步提升性能,但会导致物体根据视觉尺寸突然出现或消失。我们也可以添加中间步骤,在最终完全剔除物体之前,逐步切换到细节更少的可视化表现形式。Unity通过LOD组实现了所有这些功能
1.1 LOD Group Component
您可以通过创建空游戏对象并添加LODGroup组件来向场景中添加细节层级组。默认组定义了四个级别:LOD 0、LOD 1、LOD 2和最终剔除(即不渲染任何内容)。这些百分比代表相对于显示窗口尺寸的预估视觉大小阈值。因此,LOD 0用于覆盖窗口60%以上的物体(通常考虑垂直维度,因为这是最小尺寸

Default LOD group component
不过,质量项目设置中包含一个LOD偏差值,它会缩放这些阈值。默认设置为2,意味着评估时将预估视觉尺寸放大一倍。因此LOD 0最终会用于所有超过30%(而非60%)的物体。当偏差值设置为1以外的数值时,组件检查器会显示警告。此外还有一个最大LOD级别选项,可用于限制最高LOD级别------例如若设置为1,则LOD 1将替代LOD 0被使用。
具体做法是将所有可视化LOD层级的游戏对象设为该组对象的子级。例如,我使用了三个相同尺寸的彩色球体来代表三个LOD级别

LOD group containing three spheres
每个物体都必须分配到相应的LOD级别。您可以通过在组组件中选择级别区块,然后将物体拖拽到其渲染器列表中,或者直接将物体拖放到LOD级别区块上来实现这一点

Renderers for LOD 0
Unity会自动渲染相应的物体。在编辑器中选择特定对象会覆盖此行为,以便您能在场景中查看所选内容。如果您选择了LOD组本身,编辑器还会指示当前可见的LOD级别

Scene with LOD sphere prefab instances
移动摄像机会改变每个组使用的LOD级别。或者,您也可以调整LOD偏差值,在保持其他条件不变的情况下观察可视化效果的变化

1.2 Additive LOD Groups
附加型LOD组
物体可以被添加到多个LOD级别中。您可以使用这个功能在较高级别中添加更小的细节,而相同的大型物体可以用于多个级别。例如,我制作了一个由堆叠的扁平立方体组成的三层金字塔:底层立方体属于所有三个级别,中层立方体属于LOD 0和LOD 1,而最小的顶层立方体仅属于LOD 0。这样就能根据视觉尺寸向组中添加或移除细节,而不是完全替换整个物体

Stacked cubes LOD groups 堆叠立方体LOD组
++Can LOD groups be lightmapped?
LOD组可以参与光照贴图吗?
是的。当您让LOD组贡献GI时,它确实会被包含在光照贴图处理中。LOD 0会按预期用于光照贴图,其他LOD级别也会获得烘焙光照,但场景的其余部分仅考虑LOD 0。您也可以选择只烘焙某些级别,让其他级别依赖光照探针。++
1.3 LOD Transitions LOD过渡
LOD级别的突然切换在视觉上会显得很突兀,特别是当物体因自身或摄像机的轻微移动而快速连续切换时。可以通过将组的淡入淡出模式设置为交叉淡入淡出来实现渐进式过渡------旧级别淡出的同时新级别会同步淡入

Cross-fade mode
++What about the Speed Tree fade mode option?
那SpeedTree淡入淡出模式选项呢?
该模式专为SpeedTree树木设计,它使用自己的LOD系统来折叠树木并在3D模型和公告牌表示之间过渡。我们不会使用它。++
您可以按LOD级别控制交叉淡入淡出到下一级别的起始时机。启用交叉淡入淡出后会出现此选项。淡入淡出过渡宽度为零表示此级别与下一较低级别之间无淡入淡出效果,而值为1表示立即开始淡入淡出。在默认设置下,值为0.5时,LOD 0会在80%时开始向LOD 1交叉淡入淡出。

Fade transition width
当交叉淡入淡出激活时,两个LOD级别会同时渲染。具体如何混合它们取决于着色器的实现。Unity会为LOD_FADE_CROSSFADE关键字选择对应的着色器变体,因此请为我们的Lit着色器添加相应的multi-compile指令。这需要同时在CustomLit通道和ShadowCaster通道中添加
cs
#pragma multi_compile _ LOD_FADE_CROSSFADE
物体的淡入淡出程度通过UnityPerDraw缓冲区的unity_LODFade向量传递(我们已定义该向量)。其X分量包含淡入淡出因子,Y分量包含相同的因子但量化为16个阶梯(我们不会使用)。如果正在使用淡入淡出,我们可以在LitPassFragment开头返回该因子来进行可视化
cs
float4 LitPassFragment (Varyings input) : SV_TARGET {
UNITY_SETUP_INSTANCE_ID(input);
#if defined(LOD_FADE_CROSSFADE)
return unity_LODFade.x;
#endif
...
}

LOD fade factor LOD淡入淡出因子
正在淡出的物体如预期那样从因子1开始逐渐减少到零。但我们还看到代表更高LOD级别的纯黑色物体。这是因为正在淡入的物体的淡入淡出因子被取反了。我们可以通过返回取反后的淡入淡出因子来观察这一现象
cs
return -unity_LODFade.x;

Negated fade factor 取反后的淡入淡出因子
请注意,同时存在于两个LOD级别中的物体不会与自身进行交叉淡入淡出
1.4 Dithering
要混合两个LOD级别,我们可以使用裁剪方法,采用类似于近似半透明阴影的处理方式。由于我们需要同时对表面及其阴影进行此操作,让我们在Common中添加一个ClipLOD函数。该函数以裁剪空间XY坐标和淡入淡出因子作为参数。然后------如果交叉淡入淡出处于活动状态------根据淡入淡出因子减去抖动图案的结果进行裁剪
cs
void ClipLOD (float2 positionCS, float fade) {
#if defined(LOD_FADE_CROSSFADE)
float dither = 0;
clip(fade - dither);
#endif
}
为了检查裁剪是否按预期工作,我们将从每32像素重复一次的垂直渐变开始。这应该会创建交替的水平条纹
cs
float dither = (positionCS.y % 32) / 32;
在LitPassFragment中调用ClipLOD而不是返回淡入淡出因子
cs
//#if defined(LOD_FADE_CROSSFADE)
// return unity_LODFade.x;
//#endif
ClipLOD(input.positionCS.xy, unity_LODFade.x);
同时在ShadowCasterPassFragment开头调用它以实现阴影的交叉淡入淡出
cs
void ShadowCasterPassFragment (Varyings input) {
UNITY_SETUP_INSTANCE_ID(input);
ClipLOD(input.positionCS.xy, unity_LODFade.x);
...
}

LOD stripes, half LOD条纹,减半效果
我们得到了条纹状渲染效果,但在交叉淡入淡出时两个LOD级别中只有一个显示出来。这是因为其中一个具有负的淡入淡出因子。我们通过在此时改为添加(而非减去)抖动图案来解决这个问题
cs
clip(fade + (fade < 0.0 ? dither : -dither));

LOD stripes, complete. LOD条纹,完整效果
现在功能正常了,我们可以切换到合适的抖动图案。让我们选择与半透明阴影相同的图案
cs
float dither = InterleavedGradientNoise(positionCS.xy, 0);

Dithered LOD 抖动LOD
1.5 Animated Cross-Fading 动画交叉淡入淡出
尽管抖动创造了相当平滑的过渡,但图案仍然明显。就像半透明阴影一样,淡入淡出的阴影不稳定且分散注意力。理想情况下,交叉淡入淡出只是暂时的,即使其他条件不变。我们可以通过启用LOD组的"动画交叉淡入淡出"选项来实现这一点。该选项会忽略淡入淡出过渡宽度,并在组通过LOD阈值时快速进行交叉淡入淡出

Animated cross-fading
默认动画时长为半秒,可以通过设置静态属性LODGroup.crossFadeAnimationDuration来更改所有组的时长。不过,在非运行模式下,Unity 2022中的过渡速度会更快
2. Reflections 反射效果
另一个为场景增添细节和真实感的现象是环境镜面反射(镜子是最明显的例子),我们目前尚未支持这一功能。这对于金属表面尤其重要,因为目前它们大多呈现黑色。为了更清楚地展示这一点,我在烘焙光照场景中添加了一些具有不同颜色和光滑度的新金属球体。

Scene without reflections 无反射的场景
2.1 Indirect BRDF 间接光照BRDF
我们已经支持了依赖于BRDF漫反射颜色的漫反射全局光照。现在我们要额外添加依赖于BRDF的镜面反射全局光照。为此在BRDF中添加一个IndirectBRDF函数,参数包含表面数据、BRDF数据以及从全局光照获取的漫反射和镜面反射颜色。初始版本先让其仅返回反射的漫射光
cs
float3 IndirectBRDF (
Surface surface, BRDF brdf, float3 diffuse, float3 specular
) {
return diffuse * brdf.diffuse;
}
添加镜面反射的初始步骤类似:只需包含与BRDF镜面颜色相乘的镜面全局光照即可
cs
float3 reflection = specular * brdf.specular;
return diffuse * brdf.diffuse + reflection;
但粗糙度会散射这种反射,因此应该减少我们最终看到的镜面反射。我们通过将其除以粗糙度平方加一来实现这一点。这样低粗糙度值影响不大,而最大粗糙度会使反射减半
cs
float3 reflection = specular * brdf.specular;
reflection /= brdf.roughness * brdf.roughness + 1.0;
在GetLighting中调用IndirectBRDF,而不是直接计算漫反射间接光。开始时使用白色作为镜面全局光照颜色。
cs
float3 GetLighting (Surface surfaceWS, BRDF brdf, GI gi) {
ShadowData shadowData = GetShadowData(surfaceWS);
shadowData.shadowMask = gi.shadowMask;
float3 color = IndirectBRDF(surfaceWS, brdf, gi.diffuse, 1.0);
for (int i = 0; i < GetDirectionalLightCount(); i++) {
Light light = GetDirectionalLight(i, surfaceWS, shadowData);
color += GetLighting(surfaceWS, brdf, light);
}
return color;
}

Reflecting a white environment 反射白色环境
所有物体都至少变亮了一点,因为我们添加了之前缺失的光照。金属表面的变化尤为显著:它们的颜色现在变得明亮而突出
2.2 Sampling the Environment 采样环境
镜面反射会映照环境(默认为天空盒),该环境通过unity_SpecCube0以立方体贴图纹理的形式提供。请在GI中使用TEXTURECUBE宏声明该纹理及其采样器状态
cs
TEXTURECUBE(unity_SpecCube0);
SAMPLER(samplerunity_SpecCube0);
然后添加一个带有世界空间表面参数的SampleEnvironment函数,对纹理进行采样并返回其RGB分量。我们通过SAMPLE_TEXTURECUBE_LOD宏对立方体贴图进行采样,该宏接收贴图、采样器状态、UVW坐标和mip级别作为参数。由于是立方体贴图,我们需要3D纹理坐标(因此使用UVW)。我们从始终使用最高mip级别开始,这样我们采样的是全分辨率纹理
cs
float3 SampleEnvironment (Surface surfaceWS) {
float3 uvw = 0.0;
float4 environment = SAMPLE_TEXTURECUBE_LOD(
unity_SpecCube0, samplerunity_SpecCube0, uvw, 0.0
);
return environment.rgb;
}
对立方体贴图进行采样需要使用方向向量,这里指的是从相机到表面的视线方向经表面反射后的向量。我们可以通过调用reflect函数并传入负的视线方向和表面法线作为参数来获取该向量。
cs
float3 uvw = reflect(-surfaceWS.viewDirection, surfaceWS.normal);
接下来,在GI中添加镜面反射颜色,并在GetGI中将采样到的环境贴图存储到该颜色中
cs
struct GI {
float3 diffuse;
float3 specular;
ShadowMask shadowMask;
};
...
GI GetGI (float2 lightMapUV, Surface surfaceWS) {
GI gi;
gi.diffuse = SampleLightMap(lightMapUV) + SampleLightProbe(surfaceWS);
gi.specular = SampleEnvironment(surfaceWS);
...
}
现在我们可以在GetLighting中将正确的颜色传递给 IndirectBRDF 了。
cs
float3 color = IndirectBRDF(surfaceWS, brdf, gi.diffuse, gi.specular);
最后,要使其正常工作,我们必须在CameraRenderer.DrawVisibleGeometry中设置每对象数据时指示Unity包含反射探针
cs
perObjectData =
PerObjectData.ReflectionProbes |
PerObjectData.Lightmaps | PerObjectData.ShadowMask |
PerObjectData.LightProbe | PerObjectData.OcclusionProbe |
PerObjectData.LightProbeProxyVolume |
PerObjectData.OcclusionProbeProxyVolume

Reflecting the environment probe 反射环境探针
表面现在会反射环境了。这在金属表面上非常明显,但其他表面也会反射。由于目前只是天空盒,其他物体还不会被反射,不过我们稍后会处理这个问题

Environment probe 环境探针
2.3 Rough Reflections 粗糙反射
由于粗糙度会散射镜面反射,它不仅会降低反射强度,还会使反射变得模糊,就像失焦一样。Unity通过在下层mip级别中存储环境贴图的模糊版本来近似模拟这种效果。要访问正确的mip级别,我们需要知道感知粗糙度,因此将其添加到BRDF结构体中
cs
struct BRDF {
...
float perceptualRoughness;
};
...
BRDF GetBRDF (Surface surface, bool applyAlphaToDiffuse = false) {
...
brdf.perceptualRoughness =
PerceptualSmoothnessToPerceptualRoughness(surface.smoothness);
brdf.roughness = PerceptualRoughnessToRoughness(brdf.perceptualRoughness);
return brdf;
}
我们可以依赖 PerceptualRoughnessToMipmapLevel 函数来计算给定感知粗糙度对应的正确mip级别。该函数定义在 Core RP 库的 ImageBasedLighting 文件中。这需要我们向 SampleEnvironment 添加一个 BRDF 参数
cs
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/EntityLighting.hlsl"
#include "Packages/com.unity.render-pipelines.core/ShaderLibrary/ImageBasedLighting.hlsl"
...
float3 SampleEnvironment (Surface surfaceWS, BRDF brdf) {
float3 uvw = reflect(-surfaceWS.viewDirection, surfaceWS.normal);
float mip = PerceptualRoughnessToMipmapLevel(brdf.perceptualRoughness);
float4 environment = SAMPLE_TEXTURECUBE_LOD(
unity_SpecCube0, samplerunity_SpecCube0, uvw, mip
);
return environment.rgb;
}
同时向GetGI添加所需参数并传递下去
cs
GI GetGI (float2 lightMapUV, Surface surfaceWS, BRDF brdf) {
GI gi;
gi.diffuse = SampleLightMap(lightMapUV) + SampleLightProbe(surfaceWS);
gi.specular = SampleEnvironment(surfaceWS, brdf);
...
}
最后,在LitPassFragment中提供该参数
cs
GI gi = GetGI(GI_FRAGMENT_DATA(input), surface, brdf);

Roughness blurs reflections 粗糙度使反射变得模糊
2.4 Fresnel Reflection
所有表面都有一个特性:当以掠射角观察时,它们会开始接近完美镜面,因为光线大多不受影响地从表面反射出去。这种现象被称为菲涅耳反射。实际上它比这更复杂,因为它涉及光波在不同介质边界处的透射和反射,但我们简单地采用与通用渲染管线相同的近似方法,即假设是空气-固体边界。
我们使用Schlick近似的变体来处理菲涅耳反射。在理想情况下,它用纯白色替换镜面BRDF颜色,但粗糙度会阻止反射显现。我们通过将表面光滑度和反射率相加(最大值为1)来得到最终颜色。由于它是灰度值,我们只需在BRDF中添加一个值即可
cs
struct BRDF {
...
float fresnel;
};
...
BRDF GetBRDF (Surface surface, bool applyAlphaToDiffuse = false) {
...
brdf.fresnel = saturate(surface.smoothness + 1.0 - oneMinusReflectivity);
return brdf;
}
在 IndirectBRDF 中,我们通过计算表面法线和视线方向的点积,用1减去该值,然后将结果进行四次方运算来得到菲涅耳效应的强度。这里我们可以使用 Core RP 库中方便的 Pow4 函数
cs
float fresnelStrength =
Pow4(1.0 - saturate(dot(surface.normal, surface.viewDirection)));
float3 reflection = specular * brdf.specular;
然后我们根据菲涅耳强度在BRDF镜面反射颜色和菲涅耳颜色之间进行插值,并使用插值结果对环境反射进行着色
cs
float3 reflection =
specular * lerp(brdf.specular, brdf.fresnel, fresnelStrength);

Fresnel reflections
2.5 Fresnel Slider 菲涅耳滑块
菲涅耳反射主要在几何体边缘添加反射效果。当环境贴图与物体后方颜色匹配时,这种效果很微妙;但若不匹配,反射会显得怪异且分散注意力。结构内部球体边缘的明亮反射就是一个很好的例子。
降低光滑度会消除菲涅耳反射,但也会使整个表面变得暗淡。此外,在某些情况下(例如水下)菲涅耳近似并不适用。因此让我们在Lit着色器中添加一个滑块来缩放菲涅耳反射强度
cs
_Metallic ("Metallic", Range(0, 1)) = 0
_Smoothness ("Smoothness", Range(0, 1)) = 0.5
_Fresnel ("Fresnel", Range(0, 1)) = 1
将其添加到LitInput的UnityPerMaterial缓冲区中,并为其创建一个GetFresnel函数
cs
UNITY_INSTANCING_BUFFER_START(UnityPerMaterial)
...
UNITY_DEFINE_INSTANCED_PROP(float, _Fresnel)
UNITY_INSTANCING_BUFFER_END(UnityPerMaterial)
...
float GetFresnel (float2 baseUV) {
return UNITY_ACCESS_INSTANCED_PROP(UnityPerMaterial, _Fresnel);
}
同时在UnlitInput中添加对应的虚拟函数以保持同步
cs
float GetFresnel (float2 baseUV) {
return 0.0;
}
表面现在有一个菲涅耳强度字段
cs
struct Surface {
...
float smoothness;
float fresnelStrength;
float dither;
};
我们在LitPassFragment中将其设置为与滑块属性值相等
cs
surface.smoothness = GetSmoothness(input.baseUV);
surface.fresnelStrength = GetFresnel(input.baseUV);
最后,用它来缩放我们在IndirectBRDF中使用的菲涅耳强度
cs
float fresnelStrength = surface.fresnelStrength *
Pow4(1.0 - saturate(dot(surface.normal, surface.viewDirection)));

Adjusting Fresnel strength.
https://catlikecoding.com/unity/tutorials/custom-srp/lod-and-reflections/reflections/adjusting-fresnel-strength.mp4
2.6 Reflection Probes 反射探针
默认的环境立方体贴图仅包含天空盒。要反射场景中的其他物体,我们需要通过GameObject/Light/Reflection Probe添加反射探针。这些探针会从它们所在位置将场景渲染到立方体贴图中。因此,只有靠近探针的表面反射才会看起来相对正确。通常需要在场景中放置多个探针。它们具有"重要性"和"盒体大小"属性,可用于控制每个探针影响的区域


Reflection probe inside structure 结构内部的反射探针
探针的"类型"默认设置为"已烘焙",这意味着它会被渲染一次且立方体贴图会存储在构建中。你也可以将其设置为"实时",这样贴图会随动态场景持续更新。它会像其他相机一样使用我们的渲染管线进行渲染,立方体贴图的六个面每个都需要渲染一次,因此实时反射探针的开销很大

Using three reflection probes 使用三个反射探针
每个物体仅使用一个环境探针,但场景中可以存在多个探针。因此您可能需要分割物体以获得理想的反射效果。例如,构建建筑结构的立方体最好拆分为内部和外部两部分,以便分别使用不同的反射探针。这也意味着GPU批处理会因反射探针而中断。遗憾的是,网格球体完全无法使用反射探针,最终只能使用天空盒的反射。
MeshRenderer组件提供了"锚点覆盖"功能,可用于微调它们使用的探针,而无需关注探针包围盒的大小和位置。此外还有"反射探针"选项,默认设置为"混合探针"。其理念是Unity允许在两个最佳反射探针之间进行混合。然而,此模式与SRP批处理器不兼容,因此Unity的其他渲染管线不支持此功能,我们也不会支持。如果您好奇如何混合探针,可以参考我2018年SRP教程中的反射章节,但我预计该功能将在旧版渲染管线移除后消失。未来我们将研究其他反射技术。因此目前仅有两个可用模式:关闭模式(始终使用天空盒)和简单模式(选择最重要的探针)。其他模式的功能与简单模式完全相同

Simple reflection probes mode selected 已选择简单反射探针模式
此外,反射探针还提供启用盒投影模式的选项。该模式本应通过调整反射计算方式以更好地匹配其有限的影响区域,但由于该功能同样与SRP批处理器不兼容,我们也将不予支持
2.7 Decoding Probes 解码探针
最后,我们需要确保正确解析立方体贴图的数据。这些数据可能是高动态范围或标准动态范围的,其强度也可以调整。这些设置通过unity_SpecCube0_HDR向量提供,该向量在UnityPerDraw缓冲区中位于unity_ProbesOcclusion之后
cs
CBUFFER_START(UnityPerDraw)
...
float4 unity_ProbesOcclusion;
float4 unity_SpecCube0_HDR;
...
CBUFFER_END
通过在SampleEnvironment函数的末尾调用DecodeHDREnvironment函数,并传入原始环境数据和配置参数作为参数,我们就能获得正确的颜色值
cs
float3 SampleEnvironment (Surface surfaceWS, BRDF brdf) {
...
return DecodeHDREnvironment(environment, unity_SpecCube0_HDR);
}
The next tutorial is Complex Maps. 下一节教程是《复杂贴图》