Post

自定义渲染管线:点光源与聚光灯 (翻译九)

扩展渲染管线以支持点光源和聚光灯,包含实时渲染和烘焙光照,同时实现每物体最多8个光源的限制。

自定义渲染管线:点光源与聚光灯 (翻译九)
  • 支持更多光源类型,而不仅限于方向光。
  • 包含实时点光源和聚光灯。
  • 为点光源和聚光灯烘焙光照和阴影。
  • 限制每个对象最多渲染8个其他光源。

背景:为什么需要更多光源类型?

在此之前,我们只实现了方向光。方向光是一种理想化的无限远光源,它从同一方向照射到所有表面,不考虑距离。然而,真实游戏场景中的光照远比这复杂:玩家需要点燃的灯笼、汽车的前灯、舞台上的聚光灯等。这些光源都有具体的位置,光照强度随距离变化。

点光源基础

点光源位于空间中的一个点,并向各个方向均匀地发射光线。光线照射到物体表面时,其方向是从接触点指向光源中心的连线。光强随距离的增加而减弱,并在特定距离处降至零。光强与光源距离的平方成反比。这被称为“平方反比定律”,与光在现实世界中的行为类似。

光强随距离增加而减弱。射程是指光强从最大值衰减到零值的距离。

点光源可用于模拟场景中的灯具和其他局部光源。可以用它们来制造火花或爆炸,并以逼真的方式照亮周围环境。

光源数量限制

与方向光类似,我们也只能支持有限数量的其他类型光源。场景中通常包含大量非方向光,因为它们的影响范围是有限的。对于任何给定帧,只有部分光源是可见的。因此,最大数量限制是针对单帧而言,而非整个场景。

如果可见光源数量超过限制,一些光源将被忽略。Unity 会根据重要性对可见光源列表进行排序,因此只要可见光源不变化,被忽略的光源也是一致的。但如果相机移动或场景变化,可能导致明显的光源突现(light popping)。

我们在 Lighting 类中定义最多支持 64 个其他光源:

1
const int maxDirLightCount = 4, maxOtherLightCount = 64;

传递光源数据到 GPU

与方向光一样,我们需要将光源数量和颜色发送到 GPU。对于其他光源类型,还需要发送位置信息。

首先在 Lighting 中添加着色器属性 ID 和数组字段:

1
2
3
4
5
6
7
8
static int
otherLightCountId = Shader.PropertyToID("_OtherLightCount"),
otherLightColorsId = Shader.PropertyToID("_OtherLightColors"),
otherLightPositionsId = Shader.PropertyToID("_OtherLightPositions");

static Vector4[]
otherLightColors = new Vector4[maxOtherLightCount],
otherLightPositions = new Vector4[maxOtherLightCount];

然后在 SetupLights 方法中处理其他光源的计数和数据传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void SetupLights () {
    NativeArray<VisibleLight> visibleLights = cullingResults.visibleLights;
    int dirLightCount = 0, otherLightCount = 0;
    for (int i = 0; i < visibleLights.Length; i++) {
        // ... 处理光源
    }

    buffer.SetGlobalInt(otherLightCountId, otherLightCount);
    if (otherLightCount > 0) {
        buffer.SetGlobalVectorArray(otherLightColorsId, otherLightColors);
        buffer.SetGlobalVectorArray(
            otherLightPositionsId, otherLightPositions
        );
    }
}

着色器端的数据定义

Light 着色器文件中定义其他光源的最大数量和缓冲区:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define MAX_DIRECTIONAL_LIGHT_COUNT 4
#define MAX_OTHER_LIGHT_COUNT 64

CBUFFER_START(_CustomLight)
    int _DirectionalLightCount;
    float4 _DirectionalLightColors[MAX_DIRECTIONAL_LIGHT_COUNT];
    float4 _DirectionalLightDirections[MAX_DIRECTIONAL_LIGHT_COUNT];
    float4 _DirectionalLightShadowData[MAX_DIRECTIONAL_LIGHT_COUNT];

    int _OtherLightCount;
    float4 _OtherLightColors[MAX_OTHER_LIGHT_COUNT];
    float4 _OtherLightPositions[MAX_OTHER_LIGHT_COUNT];
CBUFFER_END

添加一个函数来获取其他光源的数量:

1
2
3
int GetOtherLightCount () {
    return _OtherLightCount;
}

点光源设置方法

创建 SetupPointLight 方法来设置点光源的颜色和位置:

1
2
3
4
void SetupPointLight (int index, ref VisibleLight visibleLight) {
    otherLightColors[index] = visibleLight.finalColor;
    otherLightPositions[index] = visibleLight.localToWorldMatrix.GetColumn(3);
}

修改光源循环

修改 SetupLights 中的循环,使用 switch 语句区分不同类型的光源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (int i = 0; i < visibleLights.Length; i++) {
    VisibleLight visibleLight = visibleLights[i];

    switch (visibleLight.lightType) {
        case LightType.Directional:
            if (dirLightCount < maxDirLightCount) {
                SetupDirectionalLight(dirLightCount++, ref visibleLight);
            }
            break;
        case LightType.Point:
            if (otherLightCount < maxOtherLightCount) {
                SetupPointLight(otherLightCount++, ref visibleLight);
            }
            break;
    }
}

光照计算

在着色器中添加 GetOtherLight 函数来计算点光源的光照:

1
2
3
4
5
6
7
8
Light GetOtherLight (int index, Surface surfaceWS, ShadowData shadowData) {
    Light light;
    light.color = _OtherLightColors[index].rgb;
    float3 ray = _OtherLightPositions[index].xyz - surfaceWS.position;
    light.direction = normalize(ray);
    light.attenuation = 1.0;
    return light;
}

GetLighting 函数中添加循环来处理其他光源:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
float3 GetLighting (Surface surfaceWS, BRDF brdf, GI gi) {
    ShadowData shadowData = GetShadowData(surfaceWS);
    shadowData.shadowMask = gi.shadowMask;

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

    for (int i = 0; i < GetDirectionalLightCount(); i++) {
        Light light = GetDirectionalLight(i, surfaceWS, shadowData);
        color += GetLighting(surfaceWS, brdf, light);
    }

    for (int j = 0; j < GetOtherLightCount(); j++) {
        Light light = GetOtherLight(j, surfaceWS, shadowData);
        color += GetLighting(surfaceWS, brdf, light);
    }

    return color;
}
场景中的点光源渲染效果
场景中的点光源渲染效果

距离衰减

逆平方定律

目前的点光源虽然可以工作,但亮度衰减不正确。光从光源发出后会散开,距离越远,光线越分散,亮度也随之降低。光照强度遵循逆平方定律

\[I = \frac{i}{d^2}\]

其中 $i$ 是配置的强度,$d$ 是距离。这意味着在距离小于 1 的地方,强度会大于配置值——光源附近会非常亮。

GetOtherLight 中应用距离衰减:

1
2
float distanceSqr = max(dot(ray, ray), 0.00001);
light.attenuation = 1.0 / distanceSqr;
距离衰减效果和曲线图
距离衰减效果和曲线图
距离衰减效果和曲线图

光源范围

虽然点光源强度随距离快速衰减,但理论上它们仍然会影响所有物体,即使距离很远。为了实际渲染,我们使用最大光源范围,超过该范围后强制将光照强度设为 0。

这样点光源被限制在一个由位置和范围定义的边界球体内。

我们不会在范围边界突然切断光照,而是使用范围衰减来平滑淡出。Unity 的通用渲染管线(URP)和光照贴图使用以下公式:

\[f(x) = \max\left(0, 1 - \left(\frac{d^2}{r^2}\right)^2\right)\]

其中 $d$ 是距离,$r$ 是光源范围。

存储范围数据

将范围存储在位置向量的第四个分量中。存储 $1/r^2$ 而不是 $r$,以减少着色器中的计算:

1
2
3
4
5
6
void SetupPointLight (int index, ref VisibleLight visibleLight) {
    otherLightColors[index] = visibleLight.finalColor;
    Vector4 position = visibleLight.localToWorldMatrix.GetColumn(3);
    position.w = 1f / Mathf.Max(visibleLight.range * visibleLight.range, 0.00001f);
    otherLightPositions[index] = position;
}

在着色器中应用范围衰减:

1
2
3
4
5
float distanceSqr = max(dot(ray, ray), 0.00001);
float rangeAttenuation = Square(
    saturate(1.0 - Square(distanceSqr * _OtherLightPositions[index].w))
);
light.attenuation = rangeAttenuation / distanceSqr;
范围衰减效果和曲线图
范围衰减效果和曲线图
范围衰减效果和曲线图

聚光灯

与点光源类似,聚光灯也具有特定的位置和范围,光线会在该范围内衰减。然而,聚光灯的照射角度受到限制,从而形成一个锥形照明区域。锥体的中心指向光源的前方(Z轴)方向。聚光灯锥体边缘的光线也会减弱。增大照射角度会增加锥体的宽度,同时也会增加这种衰减区域(称为“半影”)的大小。

光锥的宽度称为角度,光线强度从全强度到零强度变化的距离称为射程。
光锥的宽度称为角度,光线强度从全强度到零强度变化的距离称为射程。

聚光灯概述

聚光灯通常用于人造光源,例如手电筒、汽车前灯和探照灯。通过脚本或动画控制方向,移动的聚光灯可以照亮场景中的一小部分区域,从而营造出戏剧性的光影效果。

方向属性

聚光灯既有位置也有方向。需要在 Lighting 中添加方向属性和数组:

1
2
3
4
5
6
7
8
9
10
static int
otherLightCountId = Shader.PropertyToID("_OtherLightCount"),
otherLightColorsId = Shader.PropertyToID("_OtherLightColors"),
otherLightPositionsId = Shader.PropertyToID("_OtherLightPositions"),
otherLightDirectionsId = Shader.PropertyToID("_OtherLightDirections");

static Vector4[]
otherLightColors = new Vector4[maxOtherLightCount],
otherLightPositions = new Vector4[maxOtherLightCount],
otherLightDirections = new Vector4[maxOtherLightCount];

创建 SetupSpotLight 方法,类似于 SetupPointLight,但额外存储光源方向:

1
2
3
4
5
6
7
void SetupSpotLight (int index, ref VisibleLight visibleLight) {
    otherLightColors[index] = visibleLight.finalColor;
    Vector4 position = visibleLight.localToWorldMatrix.GetColumn(3);
    position.w = 1f / Mathf.Max(visibleLight.range * visibleLight.range, 0.00001f);
    otherLightPositions[index] = position;
    otherLightDirections[index] = -visibleLight.localToWorldMatrix.GetColumn(2);
}

在循环中添加聚光灯的处理分支:

1
2
3
4
5
case LightType.Spot:
    if (otherLightCount < maxOtherLightCount) {
        SetupSpotLight(otherLightCount++, ref visibleLight);
    }
    break;

着色器中的聚光灯衰减

在缓冲区中添加方向数组,并在 GetOtherLight 中应用聚光灯衰减。简单做法是使用聚光灯方向和光线方向点积的饱和值:

1
2
3
4
float4 _OtherLightDirections[MAX_OTHER_LIGHT_COUNT];

float spotAttenuation = saturate(dot(_OtherLightDirections[index].xyz, light.direction));
light.attenuation = spotAttenuation * rangeAttenuation / distanceSqr;
聚光灯效果和角度衰减
聚光灯效果和角度衰减
聚光灯效果和角度衰减

聚光灯角度控制

聚光灯有一个角度来控制光锥的宽度。这个角度是从中心测量的,所以 90° 角相当于上面看到的效果。除此之外,还有单独的内角(inner angle)控制光照何时开始衰减。

URP 和光照贴图使用的公式是:

\[\text{attenuation} = \text{saturate}\left(\frac{d - \cos(\theta_{outer})}{\cos(\theta_{inner}) - \cos(\theta_{outer})}\right)^2\]

其中 $d$ 是点积,$\cos(\theta_{inner})$ 和 $\cos(\theta_{outer})$ 分别是内外角度的余弦值。

角度参数计算

定义聚光灯角度数组:

1
2
3
4
5
6
7
8
9
10
11
12
static int
otherLightCountId = Shader.PropertyToID("_OtherLightCount"),
otherLightColorsId = Shader.PropertyToID("_OtherLightColors"),
otherLightPositionsId = Shader.PropertyToID("_OtherLightPositions"),
otherLightDirectionsId = Shader.PropertyToID("_OtherLightDirections"),
otherLightSpotAnglesId = Shader.PropertyToID("_OtherLightSpotAngles");

static Vector4[]
otherLightColors = new Vector4[maxOtherLightCount],
otherLightPositions = new Vector4[maxOtherLightCount],
otherLightDirections = new Vector4[maxOtherLightCount],
otherLightSpotAngles = new Vector4[maxOtherLightCount];

SetupSpotLight 中计算角度参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void SetupSpotLight (int index, ref VisibleLight visibleLight) {
    otherLightColors[index] = visibleLight.finalColor;
    Vector4 position = visibleLight.localToWorldMatrix.GetColumn(3);
    position.w = 1f / Mathf.Max(visibleLight.range * visibleLight.range, 0.00001f);
    otherLightPositions[index] = position;
    otherLightDirections[index] = -visibleLight.localToWorldMatrix.GetColumn(2);

    Light light = visibleLight.light;
    float innerCos = Mathf.Cos(Mathf.Deg2Rad * 0.5f * light.innerSpotAngle);
    float outerCos = Mathf.Cos(Mathf.Deg2Rad * 0.5f * visibleLight.spotAngle);
    float angleRangeInv = 1f / Mathf.Max(innerCos - outerCos, 0.001f);
    otherLightSpotAngles[index] = new Vector4(
        angleRangeInv, -outerCos * angleRangeInv
    );
}

注意:内角是 Unity 的新功能。VisibleLight 结构体可能没有存储它,因为这会改变其大小并需要重构 Unity 内部代码。

着色器中的角度衰减

在着色器中添加角度数组并调整衰减计算:

1
2
3
4
5
6
7
8
9
float4 _OtherLightDirections[MAX_OTHER_LIGHT_COUNT];
float4 _OtherLightSpotAngles[MAX_OTHER_LIGHT_COUNT];

float4 spotAngles = _OtherLightSpotAngles[index];
float spotAttenuation = Square(
    saturate(dot(_OtherLightDirections[index].xyz, light.direction) *
    spotAngles.x + spotAngles.y)
);
light.attenuation = spotAttenuation * rangeAttenuation / distanceSqr;

确保点光源不受角度衰减影响:

1
2
3
4
5
6
7
void SetupPointLight (int index, ref VisibleLight visibleLight) {
    otherLightColors[index] = visibleLight.finalColor;
    Vector4 position = visibleLight.localToWorldMatrix.GetColumn(3);
    position.w = 1f / Mathf.Max(visibleLight.range * visibleLight.range, 0.00001f);
    otherLightPositions[index] = position;
    otherLightSpotAngles[index] = new Vector4(0f, 1f);
}
内角外角调节滑块和不同内角效果
内角外角调节滑块和不同内角效果
内角外角调节滑块和不同内角效果

配置内角

默认情况下,Unity 的光源检视面板不显示内角选项。我们可以创建自定义编辑器脚本来暴露这个功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using UnityEngine;
using UnityEditor;

[CanEditMultipleObjects]
[CustomEditorForRenderPipeline(typeof(Light), typeof(CustomRenderPipelineAsset))]
public class CustomLightEditor : LightEditor {}

public override void OnInspectorGUI () {
    base.OnInspectorGUI();

    if (
        !settings.lightType.hasMultipleDifferentValues &&
        (LightType)settings.lightType.enumValueIndex == LightType.Spot
    ) {
        settings.DrawInnerAndOuterSpotAngle();
        settings.ApplyModifiedProperties();
    }
}

这个脚本扩展了 LightEditor,并使用 CustomEditorForRenderPipeline 属性来覆盖默认检视面板。对于聚光灯,它会显示内角-外角滑块。

烘焙光照和阴影

本教程暂不涉及点光源和聚光灯的实时阴影,但我们将支持烘焙这些光源类型。

完全烘焙

将点光源和聚光灯的 Mode 设置为 Baked 即可进行烘焙。注意,默认情况下它们的阴影类型设置为 None,如果需要烘焙阴影,需要更改阴影类型。

烘焙光照效果
烘焙光照效果

光照衰减委托

烘焙点光源和聚光灯时,发现它们烘焙后太亮。这是因为 Unity 默认使用了不正确的光照衰减,与旧版渲染管线的结果相匹配。

我们可以通过提供一个委托(delegate)来告诉 Unity 使用不同的衰减方式。将 CustomRenderPipeline 改为 partial 类,并在构造函数末尾调用 InitializeForEditor 方法:

1
2
3
4
5
6
7
8
9
public partial class CustomRenderPipeline : RenderPipeline {
    public CustomRenderPipeline (
        bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher,
        ShadowSettings shadowSettings
    ) {
        // ...
        InitializeForEditor();
    }
}

然后创建编辑器专用的 partial 类定义这个方法:

1
2
3
4
5
6
7
8
using Unity.Collections;
using UnityEngine;
using UnityEngine.Experimental.GlobalIllumination;
using LightType = UnityEngine.LightType;

public partial class CustomRenderPipeline {
    partial void InitializeForEditor ();
}

在编辑器中,我们需要重写光照贴图器设置光照数据的方式。通过提供一个委托,将数据从输入的 Light 数组传输到 NativeArray<LightDataGI> 输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#if UNITY_EDITOR
static Lightmapping.RequestLightsDelegate lightsDelegate =
(Light[] lights, NativeArray<LightDataGI> output) => {
    var lightData = new LightDataGI();
    for (int i = 0; i < lights.Length; i++) {
        Light light = lights[i];
        switch (light.type) {
            case LightType.Directional:
                var directionalLight = new DirectionalLight();
                LightmapperUtils.Extract(light, ref directionalLight);
                lightData.Init(ref directionalLight);
                break;
            case LightType.Point:
                var pointLight = new PointLight();
                LightmapperUtils.Extract(light, ref pointLight);
                lightData.Init(ref pointLight);
                break;
            case LightType.Spot:
                var spotLight = new SpotLight();
                LightmapperUtils.Extract(light, ref spotLight);
                // Unity 2022+ 可以设置聚光灯内角和衰减
                spotLight.innerConeAngle = light.innerSpotAngle * Mathf.Deg2Rad;
                spotLight.angularFalloff = AngularFalloffType.AnalyticAndInnerAngle;
                lightData.Init(ref spotLight);
                break;
            case LightType.Area:
                var rectangleLight = new RectangleLight();
                LightmapperUtils.Extract(light, ref rectangleLight);
                rectangleLight.mode = LightMode.Baked;
                lightData.Init(ref rectangleLight);
                break;
            default:
                lightData.InitNoBake(light.GetInstanceID());
                break;
        }
        // 关键:设置正确的衰减类型
        lightData.falloff = FalloffType.InverseSquared;
        output[i] = lightData;
    }
};
#endif

创建编辑器版本的 InitializeForEditor 方法来注册这个委托:

1
2
3
4
5
#if UNITY_EDITOR
partial void InitializeForEditor () {
    Lightmapping.SetDelegate(lightsDelegate);
}
#endif

当渲染管线被销毁时,需要清理并重置委托:

1
2
3
4
5
6
protected override void Dispose (bool disposing) {
    base.Dispose(disposing);
    #if UNITY_EDITOR
    Lightmapping.ResetDelegate();
    #endif
}
使用正确衰减烘焙的效果
使用正确衰减烘焙的效果

阴影遮罩

点光源和聚光灯的阴影也可以烘焙到阴影遮罩中,只需将它们的 Mode 设置为 Mixed。每个光源会获得一个通道,与方向光类似。但由于它们的范围是有限的,多个光源可以共用同一个通道,只要它们不重叠。因此阴影遮罩可以支持任意数量的光源,但每个像素最多只能有四个。

如果多个光源重叠并试图使用同一通道,最不重要的光源将被强制切换到 Baked 模式,直到不再冲突。

带有点光源和聚光灯的阴影遮罩
带有点光源和聚光灯的阴影遮罩

实现阴影遮罩支持

Shadows 中添加 ReserveOtherShadows 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public Vector4 ReserveOtherShadows (Light light, int visibleLightIndex) {
    if (light.shadows != LightShadows.None && light.shadowStrength > 0f) {
        LightBakingOutput lightBaking = light.bakingOutput;
        if (
            lightBaking.lightmapBakeType == LightmapBakeType.Mixed &&
            lightBaking.mixedLightingMode == MixedLightingMode.Shadowmask
        ) {
            useShadowMask = true;
            return new Vector4(
                light.shadowStrength, 0f, 0f,
                lightBaking.occlusionMaskChannel
            );
        }
    }
    return new Vector4(0f, 0f, 0f, -1f);
}

Lighting 中添加阴影数据属性和数组:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
static int
otherLightCountId = Shader.PropertyToID("_OtherLightCount"),
otherLightColorsId = Shader.PropertyToID("_OtherLightColors"),
otherLightPositionsId = Shader.PropertyToID("_OtherLightPositions"),
otherLightDirectionsId = Shader.PropertyToID("_OtherLightDirections"),
otherLightSpotAnglesId = Shader.PropertyToID("_OtherLightSpotAngles"),
otherLightShadowDataId = Shader.PropertyToID("_OtherLightShadowData");

static Vector4[]
otherLightColors = new Vector4[maxOtherLightCount],
otherLightPositions = new Vector4[maxOtherLightCount],
otherLightDirections = new Vector4[maxOtherLightCount],
otherLightSpotAngles = new Vector4[maxOtherLightCount],
otherLightShadowData = new Vector4[maxOtherLightCount];

SetupLights 中传递阴影数据到 GPU:

1
2
3
4
5
6
buffer.SetGlobalVectorArray(
    otherLightSpotAnglesId, otherLightSpotAngles
);
buffer.SetGlobalVectorArray(
    otherLightShadowDataId, otherLightShadowData
);

SetupPointLightSetupSpotLight 中配置阴影数据:

1
2
3
4
5
6
7
8
9
10
void SetupPointLight (int index, ref VisibleLight visibleLight) {
    // ...
    Light light = visibleLight.light;
    otherLightShadowData[index] = shadows.ReserveOtherShadows(light, index);
}

void SetupSpotLight (int index, ref VisibleLight visibleLight) {
    // ...
    otherLightShadowData[index] = shadows.ReserveOtherShadows(light, index);
}

着色器中的阴影遮罩

Shadows 中添加 OtherShadowData 结构体和 GetOtherShadowAttenuation 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct OtherShadowData {
    float strength;
    int shadowMaskChannel;
};

float GetOtherShadowAttenuation (
    OtherShadowData other, ShadowData global, Surface surfaceWS
) {
    #if !defined(_RECEIVE_SHADOWS)
    return 1.0;
    #endif
    float shadow;
    if (other.strength > 0.0) {
        shadow = GetBakedShadow(
            global.shadowMask, other.shadowMaskChannel, other.strength
        );
    }
    else {
        shadow = 1.0;
    }
    return shadow;
}

Light 中添加阴影数据并将其纳入衰减计算:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
CBUFFER_START(_CustomLight)
    // ...
    float4 _OtherLightShadowData[MAX_OTHER_LIGHT_COUNT];
CBUFFER_END

OtherShadowData GetOtherShadowData (int lightIndex) {
    OtherShadowData data;
    data.strength = _OtherLightShadowData[lightIndex].x;
    data.shadowMaskChannel = _OtherLightShadowData[lightIndex].w;
    return data;
}

Light GetOtherLight (int index, Surface surfaceWS, ShadowData shadowData) {
    // ...
    OtherShadowData otherShadowData = GetOtherShadowData(index);
    light.attenuation =
        GetOtherShadowAttenuation(otherShadowData, shadowData, surfaceWS) *
        spotAttenuation * rangeAttenuation / distanceSqr;
    return light;
}
带有烘焙阴影的点光源和聚光灯
带有烘焙阴影的点光源和聚光灯

每物体光源数量限制

为什么需要限制?

目前,所有可见光源都会对每个渲染的片元进行评估。这对于方向光来说是可以接受的,但对于范围外的其他光源来说是不必要的工作。通常每个点光源或聚光灯只影响一小部分片元,因此做了大量无用功,会显著影响性能。

为了支持大量光源并保持良好性能,我们必须减少每个片元评估的光源数量。最简单的方法是使用 Unity 的每物体光源索引(per-object light indices)。

工作原理

Unity 会确定每个对象受哪些光源影响,并将此信息发送到 GPU。然后在渲染每个对象时,我们只需要评估相关的光源,忽略其余的。因此,光源是按对象而非按片元确定的。这对于小物体效果很好,但对于大物体不太理想,因为如果一个光源只影响物体的一小部分,它将对整个表面进行评估。此外,每个对象能影响的光源数量有限制,所以大物体更容易缺少某些光照。

由于每物体光源索引并不完美且可能遗漏一些光照,我们将使其可选。这样也可以轻松比较两种方式的视觉效果和性能。

历史问题:Unity 的每物体光源索引代码在 Unity 2018 年后曾多次损坏,有时持续数月,导致许多 bug。这也是将其设为可选的原因之一。

每物体光源数据

CameraRenderer.DrawVisibleGeometry 中添加布尔参数来指示是否使用每物体光源模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
void DrawVisibleGeometry (
    bool useDynamicBatching, bool useGPUInstancing, bool useLightsPerObject
) {
    PerObjectData lightsPerObjectFlags = useLightsPerObject ?
        PerObjectData.LightData | PerObjectData.LightIndices :
        PerObjectData.None;
    // ... 设置其他 perObjectData
    var drawingSettings = new DrawingSettings(
        unlitShaderTagId, sortingSettings
    ) {
        // ...
        perObjectData =
            PerObjectData.ReflectionProbes |
            PerObjectData.Lightmaps | PerObjectData.ShadowMask |
            PerObjectData.LightProbe | PerObjectData.OcclusionProbe |
            PerObjectData.LightProbeProxyVolume |
            PerObjectData.OcclusionProbeProxyVolume |
            lightsPerObjectFlags
    };
    // ...
}

将参数传递给 Render 方法:

1
2
3
4
5
6
7
8
9
10
11
public void Render (
    ScriptableRenderContext context, Camera camera,
    bool useDynamicBatching, bool useGPUInstancing, bool useLightsPerObject,
    ShadowSettings shadowSettings
) {
    // ...
    DrawVisibleGeometry(
        useDynamicBatching, useGPUInstancing, useLightsPerObject
    );
    // ...
}

CustomRenderPipeline 中跟踪这个模式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
bool useDynamicBatching, useGPUInstancing, useLightsPerObject;
ShadowSettings shadowSettings;

public CustomRenderPipeline (
    bool useDynamicBatching, bool useGPUInstancing, bool useSRPBatcher,
    bool useLightsPerObject, ShadowSettings shadowSettings
) {
    this.shadowSettings = shadowSettings;
    this.useDynamicBatching = useDynamicBatching;
    this.useGPUInstancing = useGPUInstancing;
    this.useLightsPerObject = useLightsPerObject;
    // ...
}

// 在 Render 方法中传递
for (int i = 0; i < cameras.Count; i++) {
    renderer.Render(
        context, cameras[i],
        useDynamicBatching, useGPUInstancing, useLightsPerObject,
        shadowSettings
    );
}

CustomRenderPipelineAsset 中添加开关选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
[SerializeField]
bool
useDynamicBatching = true,
useGPUInstancing = true,
useSRPBatcher = true,
useLightsPerObject = true;

protected override RenderPipeline CreatePipeline () {
    return new CustomRenderPipeline(
        useDynamicBatching, useGPUInstancing, useSRPBatcher,
        useLightsPerObject, shadows
    );
}

清理光源索引

Unity 会为每个对象创建一个所有活动光源的列表,按重要性排序。这个列表包含所有光源,无论它们是否可见,也包括方向光。我们需要清理这个列表,只保留可见的非方向光索引。

Lighting.SetupLights 中处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
public void Setup (
    ScriptableRenderContext context, CullingResults cullingResults,
    ShadowSettings shadowSettings, bool useLightsPerObject
) {
    // ...
    SetupLights(useLightsPerObject);
}

void SetupLights (bool useLightsPerObject) {
    NativeArray<int> indexMap = useLightsPerObject ?
        cullingResults.GetLightIndexMap(Allocator.Temp) : default;

    NativeArray<VisibleLight> visibleLights = cullingResults.visibleLights;
    // ...

    for (int i = 0; i < visibleLights.Length; i++) {
        int newIndex = -1;
        VisibleLight visibleLight = visibleLights[i];
        switch (visibleLight.lightType) {
            // ... 处理方向光
            case LightType.Point:
                if (otherLightCount < maxOtherLightCount) {
                    newIndex = otherLightCount;
                    SetupPointLight(otherLightCount++, ref visibleLight);
                }
                break;
            case LightType.Spot:
                if (otherLightCount < maxOtherLightCount) {
                    newIndex = otherLightCount;
                    SetupSpotLight(otherLightCount++, ref visibleLight);
                }
                break;
        }
        if (useLightsPerObject) {
            indexMap[i] = newIndex;
        }
    }

    // 消除不可见光源的索引
    if (useLightsPerObject) {
        for (int i = visibleLights.Length; i < indexMap.Length; i++) {
            indexMap[i] = -1;
        }
        cullingResults.SetLightIndexMap(indexMap);
        indexMap.Dispose();
    }
}

根据是否使用每物体光源来启用或禁用着色器关键字:

1
2
3
4
5
6
7
8
9
10
11
12
static string lightsPerObjectKeyword = "_LIGHTS_PER_OBJECT";

void SetupLights (bool useLightsPerObject) {
    // ...
    if (useLightsPerObject) {
        // ...
        Shader.EnableKeyword(lightsPerObjectKeyword);
    }
    else {
        Shader.DisableKeyword(lightsPerObjectKeyword);
    }
}

使用光源索引

在 Lit 着色器的 CustomLit Pass 中添加多编译指令:

1
#pragma multi_compile _ _LIGHTS_PER_OBJECT

所需数据是 UnityPerDraw 缓冲区的一部分,由两个 real4 值组成,定义在 unity_WorldTransformParams 之后:

1
2
3
real4 unity_WorldTransformParams;
real4 unity_LightData;
real4 unity_LightIndices[2];

GetLighting 中使用替代循环来处理其他光源:

1
2
3
4
5
6
7
8
9
10
11
12
#if defined(_LIGHTS_PER_OBJECT)
for (int j = 0; j < min(unity_LightData.y, 8); j++) {
    int lightIndex = unity_LightIndices[(uint)j / 4][(uint)j % 4];
    Light light = GetOtherLight(lightIndex, surfaceWS, shadowData);
    color += GetLighting(surfaceWS, brdf, light);
}
#else
for (int j = 0; j < GetOtherLightCount(); j++) {
    Light light = GetOtherLight(j, surfaceWS, shadowData);
    color += GetLighting(surfaceWS, brdf, light);
}
#endif
每物体8个光源限制和开关
每物体8个光源限制和开关
每物体8个光源限制和开关

性能注意事项

需要注意的是,启用每物体光源后 GPU 实例化效率会降低,因为只有光源数量和索引列表匹配的对象才能被分组。SRP Batcher 不受影响,因为每个对象仍然获得自己的优化绘制调用。

总结

在本教程中,我成功实现了:

  1. 点光源支持 - 添加了位置属性和距离衰减计算
  2. 聚光灯支持 - 添加了方向控制和角度衰减,包括内角和外角
  3. 光源范围 - 实现了基于范围的光照衰减
  4. 烘焙光照 - 为点光源和聚光灯添加了烘焙支持,包括正确的衰减类型
  5. 阴影遮罩 - 支持混合模式光源的阴影遮罩
  6. 每物体光源限制 - 实现了每物体最多8个光源的限制,大幅优化性能
  7. 自定义编辑器 - 暴露了聚光灯的内角配置选项
所有光源类型汇总
所有光源类型汇总

下一篇翻译文章点光源和聚光灯阴影

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