Post

自定义管线:LOD和反射细节 (翻译七)

自定义管线:LOD和反射细节 (翻译七)
  • 使用LOD Groups。
  • LOD 级别之间的渐变淡入淡出。
  • 通过采样反射探针来反射环境。
  • 支持可选的菲涅尔反射。

Core Principles

许多小型物体可以为场景增添细节,使其更加有趣。然而,太小而无法覆盖多个像素的细节会退化为模糊的噪点。在这些视觉尺度上,最好不渲染它们,这样也可以释放 CPU 和 GPU 来渲染更重要的东西。我们还可以决定在物体仍然可以区分时更早地剔除它们。这会进一步提高性能,但会导致物体根据其视觉大小突然弹出和消失。我们还可以添加中间步骤,在最终完全剔除物体之前,逐步切换到细节更少的可视化效果。Unity 通过使用 LOD 组来实现所有这些功能。

LOD Group 组件

你可以通过创建一个空游戏对象并为其添加 LODGroup 组件来向场景添加细节层次组。默认组定义了四个级别:LOD 0、LOD 1、LOD 2,最后是剔除,这意味着什么都不渲染。这些百分比代表估计视觉大小的阈值,相对于显示窗口尺寸。因此,LOD 0 用于覆盖窗口超过 60% 的物体,通常考虑垂直尺寸,因为这是最小的。

默认 LOD 组组件
默认 LOD 组组件

然而,Quality 项目设置部分包含一个 LOD Bias,用于缩放这些阈值。它默认设置为 2,这意味着它将估计的视觉大小翻倍。因此,LOD 0 最终用于超过 30% 而不是仅 60% 的所有物体。当偏差设置为除 1 以外的值时,组件的检视面板会显示警告。除此之外,还有一个 Maximum LOD Level 选项,可用于限制最高的 LOD 级别。例如,如果设置为 1,则 LOD 1 也会被使用,而不是 LOD 0。

这个想法是,你可以将所有表示 LOD 级别的游戏对象作为组对象的子对象。例如,我为三个 LOD 级别使用了三个相同大小的彩色球体。

包含三个球的 LOD 组
包含三个球的 LOD 组

每个物体必须分配到适当的 LOD 级别。你可以通过在组组件中选择一个级别块,然后将其拖动到其 Renderers 列表中,或者直接将其放到 LOD 级别块上来完成此操作。

LOD 0 的渲染器
LOD 0 的渲染器

Unity 会自动渲染适当的物体。在编辑器中选择特定物体会覆盖此行为,因此你可以在场景中看到你的选择。如果你选择了 LOD 组本身,编辑器还会指示当前哪个 LOD 级别是可见的。

带有 LOD 球体预制体实例的场景
带有 LOD 球体预制体实例的场景
带有 LOD 球体预制体实例的场景

移动相机会改变每个组使用的 LOD 级别。或者,你可以调整 LOD 偏差以在保持其他所有内容相同的情况下看到可视化效果的变化。

调整 LOD 偏差
调整 LOD 偏差

附加型 LOD 组

物体可以添加到多个 LOD 级别。你可以用它来为更高级别添加较小的细节,而相同较大的物体可用于多个级别。例如,我用堆叠的扁平立方体制作了一个三级金字塔。基础立方体是三个级别的一部分。中间立方体是 LOD 0 和 LOD 1 的一部分,而最小的顶部立方体只是 LOD 0 的一部分。因此,细节根据视觉大小添加到组中或从中移除,而不是替换整个物体。

堆叠立方体 LOD 组
堆叠立方体 LOD 组

LOD 过渡

LOD 级别的突然切换在视觉上可能很刺眼,特别是如果物体由于自身或相机的轻微移动而快速来回切换。可以将组的 Fade Mode 设置为 Cross Fade 来使过渡逐渐发生,这会使旧级别淡出同时新级别淡入。

交叉淡入淡出模式
交叉淡入淡出模式

你可以控制每个 LOD 级别何时开始向下一级别交叉淡入。此选项在启用交叉淡入淡出时变得可见。Fade Transition Width 为零意味着此级别和下一较低级别之间没有淡入,而值为 1 意味着它立即开始淡入。在 0.5 处,使用默认设置,LOD 0 将在 80% 开始交叉淡入到 LOD 1。

淡入淡出过渡宽度
淡入淡出过渡宽度

当交叉淡入淡出处于活动状态时,两个 LOD 级别会同时渲染。由着色器以某种方式混合它们来负责。Unity 为 LOD_FADE_CROSSFADE 关键字选择一个着色器变体,因此请将此多编译指令添加到我们的 Lit 着色器。为 CustomLit 和 ShadowCaster 这两个 Pass 都要添加。

1
#pragma multi_compile _ LOD_FADE_CROSSFADE

物体淡出多少通过 UnityPerDraw 缓冲区的 unity_LODFade 向量传达,我们已经定义了它。它的 X 分量包含淡入淡出因子。Y 分量包含相同的因子,但量化为十六步,我们不会使用。让我们在 LitPassFragment 开头可视化淡入淡出因子(如果正在使用)。

1
2
3
4
5
6
7
float4 LitPassFragment (Varyings input) : SV_TARGET {
    UNITY_SETUP_INSTANCE_ID(input);
    #if defined(LOD_FADE_CROSSFADE)
        return unity_LODFade.x;
    #endif
    ...
}
LOD 淡入淡出因子
LOD 淡入淡出因子

开始淡出的物体的因子从 1 开始减少到零,这是预期的。但我们也会看到代表更高 LOD 级别的纯黑色物体。发生这种情况是因为正在淡入的物体的淡入淡出因子被取反了。我们可以通过返回取反的淡入淡出因子来看到这一点。

1
return -unity_LODFade.x;
取反的淡入淡出因子
取反的淡入淡出因子

请注意,同时处于两个 LOD 级别的物体不会与自己进行交叉淡入淡出。

抖动混合

为了混合两个 LOD 级别,我们可以使用裁剪,采用与近似半透明阴影类似的方法。由于我们需要为表面及其阴影执行此操作,让我们为此在 Common 中添加一个 ClipLOD 函数。给它裁剪空间 XY 坐标和淡入淡出因子作为参数。然后——如果启用了交叉淡入淡出——根据淡入淡出因子减去抖动图案来裁剪。

1
2
3
4
5
6
void ClipLOD (float2 positionCS, float fade) {
    #if defined(LOD_FADE_CROSSFADE)
        float dither = 0;
        clip(fade - dither);
    #endif
}

为了验证裁剪是否按预期工作,我们从每 32 像素重复的垂直渐变开始。这应该创建交替的水平条纹。

1
float dither = (positionCS.y % 32) / 32;

在 LitPassFragment 中调用 ClipLOD 而不是返回淡入淡出因子。

1
2
3
4
//#if defined(LOD_FADE_CROSSFADE)
//    return unity_LODFade.x;
//#endif
ClipLOD(input.positionCS.xy, unity_LODFade.x);

同时也在 ShadowCasterPassFragment 开头调用它来交叉淡入淡出阴影。

1
2
3
4
5
void ShadowCasterPassFragment (Varyings input) {
    UNITY_SETUP_INSTANCE_ID(input);
    ClipLOD(input.positionCS.xy, unity_LODFade.x);
    ...
}
LOD 条纹,一半
LOD 条纹,一半

我们获得了条纹渲染,但交叉淡入淡出时只有两个 LOD 级别中的一个显示出来。这是因为两个中有一个具有负的淡入淡出因子。我们通过在这种情况下添加而不是减去抖动图案来修复它。

1
clip(fade + (fade < 0.0 ? dither : -dither));
LOD 条纹,完成
LOD 条纹,完成

既然它可以工作了,我们可以切换到适当的抖动图案。让我们选择与半透明阴影相同的那个。

1
float dither = InterleavedGradientNoise(positionCS.xy, 0);
抖动的 LOD
抖动的 LOD

动画交叉淡入淡出

虽然抖动创建了相当平滑的过渡,但图案很明显。就像半透明阴影一样,淡入淡出的阴影不稳定且分散注意力。理想情况下,交叉淡入淡出只是暂时的,即使那样没有其他变化。我们可以通过启用 LOD 组的 Animate Cross-fading 选项来实现。这会忽略淡入淡出过渡宽度,而是在组通过 LOD 阈值时快速交叉淡入淡出一次。

动画交叉淡入淡出
动画交叉淡入淡出

默认动画持续时间为半秒,可以通过设置静态 LODGroup.crossFadeAnimationDuration 属性为所有组更改此值。然而,在 Unity 2022 中,不在播放模式时过渡会更快。


Deep Analysis

反射

另一个为场景增添细节和真实感的现象是环境镜面反射——其中镜子是最明显的例子——我们还没有支持这一点。这对于金属表面尤其重要,目前它们大多是黑色的。为了更明显这一点,我在烘焙光照场景中添加了更多具有不同颜色和光滑度的金属球体。

没有反射的场景
没有反射的场景

间接 BRDF

我们已经支持了漫反射全局光照,它取决于 BRDF 的漫反射颜色。现在我们添加镜面全局光照,它也取决于 BRDF。所以让我们在 BRDF 中添加一个 IndirectBRDF 函数,带有 surface 和 brdf 参数,加上从全局光照获得的漫反射和镜面反射颜色。最初让它只返回反射的漫反射光。

1
2
3
4
5
float3 IndirectBRDF (
    Surface surface, BRDF brdf, float3 diffuse, float3 specular
) {
    return diffuse * brdf.diffuse;
}

添加镜面反射开始时类似:简单地将 BRDF 的镜面反射颜色乘以的镜面反射 GI 包含在内。

1
2
float3 reflection = specular * brdf.specular;
return diffuse * brdf.diffuse + reflection;

但是粗糙度会散射这个反射,所以它应该减少我们最终看到的镜面反射。我们通过将其除以平方粗糙度加一来实现。因此,低粗糙度值不太重要,而最大粗糙度会使反射减半。

1
2
float3 reflection = specular * brdf.specular;
reflection /= brdf.roughness * brdf.roughness + 1.0;

在 GetLighting 中调用 IndirectBRDF 而不是直接计算漫反射间接光。从使用白色的镜面反射 GI 颜色开始。

1
2
3
4
5
6
7
8
9
10
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;
}
反射白色环境
反射白色环境

一切都变得更亮了一些,因为我们添加了之前缺失的光照。对金属表面的变化是戏剧性的:它们的颜色现在明亮而明显。

采样环境

镜面反射镜像环境,默认情况下是天空盒。它作为立方体贴图纹理通过 unity_SpecCube0 提供。在 GI 中声明它及其采样器状态,这次使用 TEXTURECUBE 宏。

1
2
TEXTURECUBE(unity_SpecCube0);
SAMPLER(samplerunity_SpecCube0);

然后添加一个 SampleEnvironment 函数,带有世界空间表面参数,采样纹理,并返回其 RGB 分量。我们通过 SAMPLE_TEXTURECUBE_LOD 宏采样立方体贴图,它接受贴图、采样器状态、UVW 坐标和 mip 级别作为参数。由于它是立方体贴图,我们需要 3D 纹理坐标,因此是 UVW。我们从始终使用最高 mip 级别开始,这样我们可以采样全分辨率纹理。

1
2
3
4
5
6
7
float3 SampleEnvironment (Surface surfaceWS) {
    float3 uvw = 0.0;
    float4 environment = SAMPLE_TEXTURECUBE_LOD(
        unity_SpecCube0, samplerunity_SpecCube0, uvw, 0.0
    );
    return environment.rgb;
}

使用方向采样立方体贴图,在这种情况下方向是从相机到表面的视图方向在表面法线上反射得到。我们通过使用负视图方向和表面法线作为参数调用 reflect 函数来获得它。

1
float3 uvw = reflect(-surfaceWS.viewDirection, surfaceWS.normal);

接下来,在 GI 中添加镜面反射颜色并在 GetGI 中将采样的环境存储到其中。

1
2
3
4
5
6
7
8
9
10
11
12
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。

1
float3 color = IndirectBRDF(surfaceWS, brdf, gi.diffuse, gi.specular);

最后,为了让它工作,我们必须指示 Unity 在设置每对象数据时包含反射探针,在 CameraRenderer.DrawVisibleGeometry 中。

1
2
3
4
5
6
perObjectData =
    PerObjectData.ReflectionProbes |
    PerObjectData.Lightmaps | PerObjectData.ShadowMask |
    PerObjectData.LightProbe | PerObjectData.OcclusionProbe |
    PerObjectData.LightProbeProxyVolume |
    PerObjectData.OcclusionProbeProxyVolume
反射环境探针
反射环境探针

表面现在反射环境。这对金属表面很明显,但其他表面也会反射它。由于它只是天空盒,没有其他东西被反射,但我们稍后会看到这一点。

环境探针
环境探针

粗糙反射

由于粗糙度散射镜面反射,它不仅降低其强度,还会使其模糊,就像失焦一样。Unity 通过在较低 mip 级别存储模糊版本的环境贴图来近似这种效果。为了访问正确的 mip 级别,我们需要感知粗糙度,所以让我们将其添加到 BRDF 结构中。

1
2
3
4
5
6
7
8
9
10
11
12
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 参数。

1
2
3
4
5
6
7
8
9
10
11
#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 中添加必要的参数并传递它。

1
2
3
4
5
6
GI GetGI (float2 lightMapUV, Surface surfaceWS, BRDF brdf) {
    GI gi;
    gi.diffuse = SampleLightMap(lightMapUV) + SampleLightProbe(surfaceWS);
    gi.specular = SampleEnvironment(surfaceWS, brdf);
    ...
}

最后,在 LitPassFragment 中提供它。

1
GI gi = GetGI(GI_FRAGMENT_DATA(input), surface, brdf);
粗糙度模糊反射
粗糙度模糊反射

Fresnel 反射

所有表面都有一个特性:当以掠射角观察时,它们开始像完美镜子一样,因为光在它们上面几乎不受影响地反弹。这种现象称为 Fresnel 反射。实际上它更复杂,因为它涉及不同介质边界处光的透射和反射,但我们只使用与 Universal RP 相同的近似,即假设空气-固体边界。

我们使用 Schlick 近似的变体。它用纯白色替换理想情况下的镜面反射 BRDF 颜色,但粗糙度可以防止反射显示出来。我们通过将表面光滑度和反射率加在一起,最终颜色不超过 1 来到达最终颜色。由于它是灰度值,我们可以为它在 BRDF 中添加一个值。

1
2
3
4
5
6
7
8
9
10
struct BRDF {
    ...
    float fresnel;
};
...
BRDF GetBRDF (Surface surface, bool applyAlphaToDiffuse = false) {
    ...
    brdf.fresnel = saturate(surface.smoothness + 1.0 - oneMinusReflectivity);
    return brdf;
}

在 IndirectBRDF 中,我们通过获取表面法线和视图方向的点积,从 1 中减去它,并将结果提升到第四次方来找到 Fresnel 效果的强度。我们可以使用 Core RP 库中的便捷 Pow4 函数。

1
2
3
float fresnelStrength =
    Pow4(1.0 - saturate(dot(surface.normal, surface.viewDirection)));
float3 reflection = specular * brdf.specular;

然后我们根据强度在 BRDF 镜面反射和 fresnel 颜色之间进行插值,然后使用结果来着色环境反射。

1
2
float3 reflection =
    specular * lerp(brdf.specular, brdf.fresnel, fresnelStrength);
Fresnel 反射
Fresnel 反射

Fresnel 滑块

Fresnel 反射主要沿着几何体的边缘添加反射。当环境贴图正确匹配物体后面的颜色时效果很微妙,但如果不是这种情况,反射可能会显得奇怪且分散注意力。结构内部球体边缘的明亮反射就是一个很好的例子。降低光滑度可以消除 Fresnel 反射,但也会使整个表面变暗。此外,在某些情况下 Fresnel 近似不合适,例如水下。所以让我们添加一个滑块来缩小它在 Lit 着色器中的效果。

1
2
3
_Metallic ("Metallic", Range(0, 1)) = 0
_Smoothness ("Smoothness", Range(0, 1)) = 0.5
_Fresnel ("Fresnel", Range(0, 1)) = 1

将其添加到 LitInput 中的 UnityPerMaterial 缓冲区,并为其创建一个 GetFresnel 函数。

1
2
3
4
5
6
7
8
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 中为其添加一个虚拟函数,以保持它们同步。

1
2
3
float GetFresnel (float2 baseUV) {
    return 0.0;
}

Surface 现在为其 Fresnel 强度添加一个字段。

1
2
3
4
5
6
struct Surface {
    ...
    float smoothness;
    float fresnelStrength;
    float dither;
};

我们在 LitPassFragment 中将其设置为等于滑块属性的值。

1
2
surface.smoothness = GetSmoothness(input.baseUV);
surface.fresnelStrength = GetFresnel(input.baseUV);

最后,使用它在 IndirectBRDF 中缩放我们使用的 Fresnel 强度。

1
2
float fresnelStrength = surface.fresnelStrength *
    Pow4(1.0 - saturate(dot(surface.normal, surface.viewDirection)));
调整 Fresnel 强度
调整 Fresnel 强度

反射探针

默认环境立方体贴图只包含天空盒。为了反射场景中的任何其他东西,我们可以通过 GameObject / Light / Reflection Probe 向其添加反射探针。这些探针从它们的位置将场景渲染到立方体贴图中。因此,反射只会在接近探针的表面上显得比较正确。因此,通常需要在场景中放置多个探针。它们具有 Importance 和 Box Size 属性,可用于控制每个探针影响的区域。

结构内部的反射探针
结构内部的反射探针

探针的 Type 默认设置为 Baked,这意味着它被渲染一次,立方体贴图存储在构建中。你也可以将其设置为 Realtime,这会保持贴图与动态场景同步。它就像任何其他相机一样被渲染,使用我们的 RP,每个立方体贴图的六个面渲染一次。因此,实时反射探针非常昂贵。

使用三个反射探针
使用三个反射探针

每个对象只使用单个环境探针,但场景中可以有多个探针。因此,你可能需要拆分对象以获得可接受的反射。例如,用于构建结构的立方体理想情况下应该拆分为单独的内部和外部部分,这样每个都可以使用不同的反射探针。这也意味着 GPU 批处理会被反射探针分解。不幸的是,网格球根本无法使用反射探针,始终只得到天空盒。

MeshRenderer 组件有一个 Anchor Override,可用于微调它们使用的探针,而不必担心盒子大小和位置。还有一个 Reflection Probes 选项,默认设置为 Blend Probes。Unity 允许在两个最重要的反射探针之间进行混合。但是,此模式与 SRP 批量处理器不兼容,因此 Unity 的其他 RP 不支持它,我们也不会。支持它的方式在我的 2018 年 SRP 教程的 Reflections 教程中有解释,但我预计一旦旧管线被移除,此功能就会消失。我们将在未来研究其他反射技术。因此,只有两种功能模式:Off(始终使用天空盒)和 Simple(选择最重要的探针)。其他功能与 Simple 完全一样。

选择简单反射探针模式
选择简单反射探针模式

除此之外,反射探针还有启用盒投影模式的选项。这应该会改变反射的确定方式,以更好地匹配其有限的影响区域,但这也与 SRP 批量处理器不兼容,所以我们也不会支持它。

解码探针

最后,我们必须确保正确解释来自立方体贴图的数据。它可能是 HDR 或 LDR,其强度也可以调整。这些设置通过 unity_SpecCube0_HDR 向量提供,它在 UnityPerDraw 缓冲区中位于 unity_ProbesOcclusion 之后。

1
2
3
4
5
6
CBUFFER_START(UnityPerDraw)
    ...
    float4 unity_ProbesOcclusion;
    float4 unity_SpecCube0_HDR;
    ...
CBUFFER_END

我们通过在 SampleEnvironment 末尾使用原始环境数据和设置作为参数调用 DecodeHDREnvironment 来获得正确的颜色。

1
2
3
4
float3 SampleEnvironment (Surface surfaceWS, BRDF brdf) {
    ...
    return DecodeHDREnvironment(environment, unity_SpecCube0_HDR);
}

Practical Implementation

本教程中我们实现的关键功能:

  1. LOD Groups: 使用 LODGroup 组件管理不同细节级别的物体切换
  2. Cross-Fade: 通过 LOD_FADE_CROSSFADE 关键字和 ClipLOD 函数实现平滑过渡
  3. 环境反射: 通过 unity_SpecCube0 立方体贴图采样实现
  4. Fresnel 效果: 使用 Schlick 近似实现掠射角的镜面反射增强
  5. 反射探针: 支持在场景中添加多个探针来捕捉环境

Advanced Topics

性能优化建议

  • LOD 过渡应尽量使用动画交叉淡入淡出,避免抖动模式产生的视觉噪点
  • 实时反射探针非常昂贵,谨慎使用
  • 金属表面强烈依赖反射,非金属表面主要显示漫反射

扩展方向

  • 后续教程将介绍复杂贴图(Complex Maps)
  • 可以研究探针混合技术
  • 盒投影模式(需要 SRP 批量处理器支持)

下一个教程Complex Maps - 复杂贴图

This post is licensed under CC BY 4.0 by the author.