Post

自定义管线:渲染缩放 (翻译十六)

探索Unity渲染管线中的分辨率缩放技术,包括动态调整渲染分辨率、超采样抗锯齿和性能优化策略。

自定义管线:渲染缩放 (翻译十六)
  • 通过滑块调整渲染比例。
  • 支持每摄像机使用不同的渲染比例。
  • 在所有后期特效处理完毕后重新缩放到最终目标。

可变分辨率

应用程序以固定分辨率运行。一些应用程序允许通过设置菜单更改分辨率,但这需要完全重新初始化图形系统。更灵活的方法是保持应用程序的分辨率固定,但更改相机用于渲染的缓冲区大小。这会影响整个渲染过程,除了最后绘制到帧缓冲区时,此时结果会被重新缩放以匹配应用程序的分辨率。

缩放缓冲区大小可用于提高性能,通过减少必须处理的片段数量。例如,可以对所有 3D 渲染执行此操作,同时保持 UI 以全分辨率清晰显示。缩放比例也可以动态调整,以保持可接受的帧率。最后,我们还可以增加缓冲区大小以进行超采样,这可以减少由有限分辨率引起的锯齿伪影。最后一种方法也称为 SSAA,即超采样抗锯齿。

缓冲区设置

调整渲染缩放会影响缓冲区大小,因此我们将为 CameraBufferSettings 添加一个可配置的渲染缩放滑块。应该有一个最小缩放比例,我们将使用 0.1。我们还使用 2 作为最大值,因为如果我们使用单个双线性插值步骤重新缩放,则超过该值不会提高图像质量。超过 2 会降低质量,因为在下采样到最终目标分辨率时,我们最终会完全跳过许多像素。

1
2
3
4
5
6
7
8
using UnityEngine;

[System.Serializable]
public struct CameraBufferSettings {
    ....
    [Range(0.1f, 2f)]
    public float renderScale;
}

CustomRenderPipelineAsset 中,默认渲染缩放应设置为 1。

1
2
3
4
CameraBufferSettings cameraBuffer = new CameraBufferSettings {
    allowHDR = true,
    renderScale = 1f
};
渲染缩放滑块
渲染缩放滑块

缩放渲染

从现在开始,我们还将在 CameraRenderer 中跟踪是否正在使用缩放渲染。

1
bool useHDR, useScaledRendering;

我们不希望配置的渲染缩放影响场景窗口,因为它们用于编辑。在适当的时候,在 PrepareForSceneWindow 中关闭缩放渲染来强制执行此操作。

1
2
3
4
5
6
partial void PrepareForSceneWindow () {
    if (camera.cameraType == CameraType.SceneView) {
        ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
        useScaledRendering = false;
    }
}

我们在 Render 中调用 PrepareForSceneWindow 之前确定是否应使用缩放渲染。在变量中跟踪当前渲染缩放并检查它是否不是 1。

1
2
3
4
float renderScale = bufferSettings.renderScale;
useScaledRendering = renderScale != 1f;
PrepareBuffer();
PrepareForSceneWindow();

不过,我们应该比这更模糊一些,因为与 1 的非常轻微的偏差既不会产生重要的视觉差异,也不会产生性能差异。因此,让我们仅在至少有 1% 的差异时才使用缩放渲染。

1
useScaledRendering = renderScale < 0.99f || renderScale > 1.01f;

从现在开始,当使用缩放渲染时,我们还必须使用中间缓冲区。因此在 Setup 中检查它。

1
2
useIntermediateBuffer = useScaledRendering ||
    useColorTexture || useDepthTexture || postFXStack.IsActive;

缓冲区大小

因为我们相机的缓冲区大小现在可能与 Camera 组件指示的大小不同,所以我们必须跟踪我们最终使用的缓冲区大小。我们可以为此使用单个 Vector2Int 字段。

1
Vector2Int bufferSize;

Render 中,在剔除成功后设置适当的缓冲区大小。如果应用缩放渲染,则缩放相机的像素宽度和高度,并将结果转换为整数,向下舍入。

1
2
3
4
5
6
7
8
9
10
11
12
13
if (!Cull(shadowSettings.maxDistance)) {
    return;
}

useHDR = bufferSettings.allowHDR && camera.allowHDR;
if (useScaledRendering) {
    bufferSize.x = (int)(camera.pixelWidth * renderScale);
    bufferSize.y = (int)(camera.pixelHeight * renderScale);
}
else {
    bufferSize.x = camera.pixelWidth;
    bufferSize.y = camera.pixelHeight;
}

Setup 中获取相机附件的渲染纹理时使用此缓冲区大小。

1
2
3
4
5
6
7
8
9
buffer.GetTemporaryRT(
    colorAttachmentId, bufferSize.x, bufferSize.y,
    0, FilterMode.Bilinear, useHDR ?
        RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default
);
buffer.GetTemporaryRT(
    depthAttachmentId, bufferSize.x, bufferSize.y,
    32, FilterMode.Point, RenderTextureFormat.Depth
);

如果需要颜色和深度纹理,也使用它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void CopyAttachments () {
    if (useColorTexture) {
        buffer.GetTemporaryRT(
            colorTextureId, bufferSize.x, bufferSize.y,
            0, FilterMode.Bilinear, useHDR ?
                RenderTextureFormat.DefaultHDR : RenderTextureFormat.Default
        );
        ....
    }
    if (useDepthTexture) {
        buffer.GetTemporaryRT(
            depthTextureId, bufferSize.x, bufferSize.y,
            32, FilterMode.Point, RenderTextureFormat.Depth
        );
        ....
    }
    ....
}

最初在没有任何后处理效果的情况下尝试这一点。你可以放大游戏窗口,这样你可以更好地看到单个像素,这使得调整后的渲染缩放更加明显。

没有后处理效果;渲染缩放 1;游戏窗口放大
没有后处理效果;渲染缩放 1;游戏窗口放大

降低渲染缩放将加快渲染速度,同时降低图像质量。增加渲染缩放则相反。请记住,当不使用后处理效果时,调整后的渲染缩放需要中间缓冲区和额外的绘制,因此这会增加一些额外的工作。

Image
Image
渲染缩放 0.25、0.5、1.5 和 2
渲染缩放 0.25、0.5、1.5 和 2
渲染缩放 0.25、0.5、1.5 和 2

到目标缓冲区大小的重新缩放由最终绘制自动完成。我们最终得到一个简单的双线性放大或缩小操作。唯一奇怪的结果涉及 HDR 值,它似乎会破坏插值。你可以在上面截图中心黄色球体的高光中看到这种情况。我们稍后将处理这个问题。

片段屏幕 UV

调整渲染缩放引入了一个错误:对颜色和深度纹理的采样出错。你可以通过粒子扭曲看到这一点,它明显最终使用了不正确的屏幕空间 UV 坐标。

不正确的扭曲,渲染缩放 1.5
不正确的扭曲,渲染缩放 1.5

发生这种情况是因为 Unity 在 _ScreenParams 中放置的值与相机的像素尺寸匹配,而不是我们正在定位的缓冲区的尺寸。我们通过引入一个替代的 _CameraBufferSize 向量来修复这个问题,该向量包含相机调整后大小的数据。

1
2
3
static int
    bufferSizeId = Shader.PropertyToID("_CameraBufferSize"),
    colorAttachmentId = Shader.PropertyToID("_CameraColorAttachment"),

在确定缓冲区大小后,我们在 Render 中将值发送到 GPU。我们将使用与 Unity 用于 _TexelSize 向量相同的格式,因此是宽度和高度的倒数,后跟宽度和高度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (useScaledRendering) {
    bufferSize.x = (int)(camera.pixelWidth * renderScale);
    bufferSize.y = (int)(camera.pixelHeight * renderScale);
}
else {
    bufferSize.x = camera.pixelWidth;
    bufferSize.y = camera.pixelHeight;
}

buffer.BeginSample(SampleName);
buffer.SetGlobalVector(bufferSizeId, new Vector4(
    1f / bufferSize.x, 1f / bufferSize.y,
    bufferSize.x, bufferSize.y
));
ExecuteBuffer();

将向量添加到 Fragment

1
2
3
TEXTURE2D(_CameraColorTexture);
TEXTURE2D(_CameraDepthTexture);
float4 _CameraBufferSize;

然后在 GetFragment 中使用它而不是 _ScreenParams。现在我们还可以使用乘法而不是除法。

1
f.screenUV = f.positionSS * _CameraBufferSize.xy;
正确的扭曲,渲染缩放 1.5
正确的扭曲,渲染缩放 1.5

缩放后处理效果

调整渲染缩放也应该影响后处理效果,否则我们最终会得到意外的缩放。最稳健的方法是始终使用相同的缓冲区大小,因此我们将其作为新的第三个参数传递给 CameraRenderer.Render 中的 PostFXStack.Setup

1
2
3
4
postFXStack.Setup(
    context, camera, bufferSize, postFXSettings, useHDR, colorLUTResolution,
    cameraSettings.finalBlendMode
);

PostFXStack 现在必须跟踪缓冲区大小。

1
2
3
4
5
6
7
8
9
10
Vector2Int bufferSize;
....
public void Setup (
    ScriptableRenderContext context, Camera camera, Vector2Int bufferSize,
    PostFXSettings settings, bool useHDR, int colorLUTResolution,
    CameraSettings.FinalBlendMode finalBlendMode
) {
    this.bufferSize = bufferSize;
    ....
}

必须在 DoBloom 中使用它,而不是直接使用相机的像素大小。

1
2
3
4
5
6
7
8
9
10
bool DoBloom (int sourceId) {
    BloomSettings bloom = settings.Bloom;
    int width = bufferSize.x / 2, height = bufferSize.y / 2;
    ....
    buffer.GetTemporaryRT(
        bloomResultId, bufferSize.x, bufferSize.y, 0,
        FilterMode.Bilinear, format
    );
    ....
}

因为泛光是一种依赖于分辨率的效果,调整渲染缩放将改变它的外观。通过只使用几次泛光迭代最容易看到这一点。降低渲染缩放将使效果变大,而增加渲染缩放将使其变小。具有最大迭代次数的泛光似乎没有太大变化,但由于分辨率的变化,在调整渲染缩放时可能会出现脉动。

两次加法泛光迭代;渲染缩放 0.5、1 和 2
两次加法泛光迭代;渲染缩放 0.5、1 和 2
两次加法泛光迭代;渲染缩放 0.5、1 和 2
两次加法泛光迭代;渲染缩放 0.5、1 和 2

特别是如果渲染缩放逐渐调整,可能希望使泛光尽可能保持一致。这可以通过基于相机而不是缓冲区大小来确定泛光金字塔的起始大小来实现。让我们通过向 BloomSettings 添加一个忽略渲染缩放的切换来使其可配置。

1
2
3
4
public struct BloomSettings {
    public bool ignoreRenderScale;
    ....
}

如果应该忽略渲染缩放,PostFXStack.DoBloom 将从相机像素大小的一半开始,就像以前一样。这意味着它不再执行默认的下采样到一半分辨率,而是依赖于渲染缩放。最终的泛光结果仍应与缩放后的缓冲区大小匹配,因此这将在最后引入另一个自动下采样或上采样步骤。

1
2
3
4
5
6
7
8
9
10
11
12
13
bool DoBloom (int sourceId) {
    BloomSettings bloom = settings.Bloom;
    int width, height;
    if (bloom.ignoreRenderScale) {
        width = camera.pixelWidth / 2;
        height = camera.pixelHeight / 2;
    }
    else {
        width = bufferSize.x / 2;
        height = bufferSize.y / 2;
    }
    ....
}

当忽略渲染缩放时,泛光现在更加一致,尽管在非常低的缩放比例下,它看起来仍然不同,这仅仅是因为可用的数据太少。

Image
忽略渲染缩放的泛光;渲染缩放 0.5、1 和 2
忽略渲染缩放的泛光;渲染缩放 0.5、1 和 2
忽略渲染缩放的泛光;渲染缩放 0.5、1 和 2
忽略渲染缩放的泛光;渲染缩放 0.5、1 和 2

每个相机的渲染缩放

让我们也可以为每个相机使用不同的渲染缩放。例如,单个相机可以始终以一半或两倍分辨率渲染。这可以是固定的——覆盖渲染管线的全局渲染缩放——或者应用于顶部,因此它相对于全局渲染缩放。

CameraSettings 添加一个渲染缩放滑块,范围与渲染管线资产相同。还添加一个渲染缩放模式,可以通过新的内部 RenderScaleMode 枚举类型设置为继承、乘法或覆盖。

1
2
3
4
5
6
public enum RenderScaleMode { Inherit, Multiply, Override }

public RenderScaleMode renderScaleMode = RenderScaleMode.Inherit;

[Range(0.1f, 2f)]
public float renderScale = 1f;
渲染缩放模式
渲染缩放模式

要应用每个相机的渲染缩放,还要给 CameraSettings 一个公共的 GetRenderScale 方法,它有一个渲染缩放参数并返回最终缩放。因此,根据模式,它返回相同的缩放、相机的缩放或两者相乘。

1
2
3
4
5
6
public float GetRenderScale (float scale) {
    return
        renderScaleMode == RenderScaleMode.Inherit ? scale :
        renderScaleMode == RenderScaleMode.Override ? renderScale :
        scale * renderScale;
}

CameraRenderer.Render 中调用该方法以获取最终渲染缩放,将缓冲区设置中的缩放传递给它。

1
float renderScale = cameraSettings.GetRenderScale(bufferSettings.renderScale);

让我们还钳制最终渲染缩放,使其在需要时保持在 0.1-2 范围内。这样,我们可以防止缩放在相乘时过小或过大。

1
2
3
4
5
if (useScaledRendering) {
    renderScale = Mathf.Clamp(renderScale, 0.1f, 2f);
    bufferSize.x = (int)(camera.pixelWidth * renderScale);
    bufferSize.y = (int)(camera.pixelHeight * renderScale);
}

由于我们对所有渲染缩放使用相同的最小值和最大值,让我们将它们定义为 CameraRenderer 的公共常量。我只显示常量的定义,而不是在 CameraRendererCameraBufferSettingsCameraSettings 中替换 0.1f2f 值。

1
public const float renderScaleMin = 0.1f, renderScaleMax = 2f;
每个相机不同的渲染缩放
每个相机不同的渲染缩放

重新缩放

当使用 1 以外的渲染缩放时,除了最终绘制到相机的目标缓冲区之外,所有操作都以该缩放进行。如果不使用后处理效果,这是一个简单的复制,它重新缩放到最终大小。当后处理效果激活时,是最终绘制隐式执行重新缩放。然而,在最终绘制期间重新缩放有一些缺点。

当前方法

我们当前的重新缩放方法会产生不希望的副作用。首先,正如我们之前已经注意到的,无论是在放大还是缩小时,亮度大于 1 的 HDR 颜色总是锯齿状的。插值仅在 LDR 中执行时才会产生平滑的结果。HDR 插值可以产生仍然大于 1 的结果,这根本不会显得混合。例如,0 和 10 的平均值是 5。在 LDR 中,它看起来好像 0 和 1 的平均值是 1,而我们本来期望它是 0.5。

Image
Image
有无 HDR 的颜色插值;渲染缩放 0.5 和 2
有无 HDR 的颜色插值;渲染缩放 0.5 和 2
有无 HDR 的颜色插值;渲染缩放 0.5 和 2

在最终 pass 期间重新缩放的第二个问题是颜色校正应用于插值颜色而不是原始颜色。这可能会引入不希望的色带。最明显的是在阴影和高光之间插值时中间色调的出现。这可以通过对中间色调应用非常强的颜色调整来使其极其明显,例如使它们变成红色。

强红色中间色调;渲染缩放 0.5、1 和 2
强红色中间色调;渲染缩放 0.5、1 和 2
强红色中间色调;渲染缩放 0.5、1 和 2
强红色中间色调;渲染缩放 0.5、1 和 2

LDR 中的重新缩放

尖锐的 HDR 边缘和颜色校正伪影都是由在颜色校正和色调映射之前插值 HDR 颜色引起的。因此,解决方案是在调整后的渲染缩放下执行两者,然后再执行另一个复制 pass,该 pass 重新缩放 LDR 颜色。向 PostFXStack 着色器添加一个新的最终重新缩放 pass 来处理这个最后的步骤。它只是一个也具有可配置混合模式的复制 pass。还像往常一样向 PostFXStack.Pass 枚举添加一个条目。

1
2
3
4
5
6
7
8
9
10
11
Pass {
    Name "Final Rescale"

    Blend [_FinalSrcBlend] [_FinalDstBlend]

    HLSLPROGRAM
        #pragma target 3.5
        #pragma vertex DefaultPassVertex
        #pragma fragment CopyPassFragment
    ENDHLSL
}

现在我们有两个最终 pass,这需要我们向 DrawFinal 添加一个 pass 参数。

1
2
3
4
5
6
7
void DrawFinal (RenderTargetIdentifier from, Pass pass) {
    ....
    buffer.DrawProcedural(
        Matrix4x4.identity, settings.Material,
        (int)pass, MeshTopology.Triangles, 3
    );
}

我们在 DoColorGradingAndToneMapping 中必须使用哪种方法现在取决于我们是否在使用调整后的渲染缩放。我们可以通过将缓冲区大小与相机的像素大小进行比较来检查这一点。检查宽度就足够了。如果它们相等,我们像以前一样绘制最终 pass,现在明确使用 Pass.Final 作为参数。

1
2
3
4
5
6
7
8
void DoColorGradingAndToneMapping (int sourceId) {
    ....
    if (bufferSize.x == camera.pixelWidth) {
        DrawFinal(sourceId, Pass.Final);
    }
    else {}
    buffer.ReleaseTemporaryRT(colorGradingLUTId);
}

但如果我们需要重新缩放,那么我们必须绘制两次。首先获取一个与当前缓冲区大小匹配的新临时渲染纹理。由于我们在其中存储 LDR 颜色,我们可以使用默认的渲染纹理格式。然后使用最终 pass 执行常规绘制,将最终混合模式设置为 One Zero。之后使用最终重新缩放 pass 执行最终绘制,然后释放中间缓冲区。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
if (bufferSize.x == camera.pixelWidth) {
    DrawFinal(sourceId, Pass.Final);
}
else {
    buffer.SetGlobalFloat(finalSrcBlendId, 1f);
    buffer.SetGlobalFloat(finalDstBlendId, 0f);
    buffer.GetTemporaryRT(
        finalResultId, bufferSize.x, bufferSize.y, 0,
        FilterMode.Bilinear, RenderTextureFormat.Default
    );
    Draw(sourceId, finalResultId, Pass.Final);
    DrawFinal(finalResultId, Pass.FinalRescale);
    buffer.ReleaseTemporaryRT(finalResultId);
}

通过这些更改,HDR 颜色似乎也能正确插值。

在 LDR 中重新缩放;渲染缩放 0.5 和 2
在 LDR 中重新缩放;渲染缩放 0.5 和 2
在 LDR 中重新缩放;渲染缩放 0.5 和 2

而且颜色分级不再引入在渲染缩放 1 时不存在的色带。

颜色校正后重新缩放;强红色中间色调;渲染缩放 0.5 和 2
颜色校正后重新缩放;强红色中间色调;渲染缩放 0.5 和 2
颜色校正后重新缩放;强红色中间色调;渲染缩放 0.5 和 2

请注意,这仅在使用后处理效果时修复了问题。否则没有颜色分级,我们也假设没有 HDR。

双三次采样

当渲染缩放降低时,图像变得块状。我们为泛光添加了使用双三次上采样的选项以提高其质量,我们可以在重新缩放到最终渲染目标时执行相同的操作。向 CameraBufferSettings 添加一个切换。

1
public bool bicubicRescaling;
双三次重新缩放切换
双三次重新缩放切换

PostFXStackPasses 添加一个新的 FinalPassFragmentRescale 函数,以及一个 _CopyBicubic 属性来控制它使用双三次还是常规采样。

1
2
3
4
5
6
7
8
9
10
bool _CopyBicubic;

float4 FinalPassFragmentRescale (Varyings input) : SV_TARGET {
    if (_CopyBicubic) {
        return GetSourceBicubic(input.screenUV);
    }
    else {
        return GetSource(input.screenUV);
    }
}

更改最终重新缩放 pass 以使用此函数而不是复制函数。

1
#pragma fragment FinalPassFragmentRescale

PostFXStack 添加属性标识符,并让它跟踪是否启用了双三次重新缩放,这通过 Setup 的新参数配置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int
    copyBicubicId = Shader.PropertyToID("_CopyBicubic"),
    finalResultId = Shader.PropertyToID("_FinalResult"),
    ....

bool bicubicRescaling;
....
public void Setup (
    ScriptableRenderContext context, Camera camera, Vector2Int bufferSize,
    PostFXSettings settings, bool useHDR, int colorLUTResolution,
    CameraSettings.FinalBlendMode finalBlendMode, bool bicubicRescaling
) {
    this.bicubicRescaling = bicubicRescaling;
    ....
}

CameraRenderer.Render 中传递缓冲区设置。

1
2
3
4
postFXStack.Setup(
    context, camera, bufferSize, postFXSettings, useHDR, colorLUTResolution,
    cameraSettings.finalBlendMode, bufferSettings.bicubicRescaling
);

在执行最终重新缩放之前,在 PostFXStack.DoColorGradingAndToneMapping 中适当地设置着色器属性。

1
2
buffer.SetGlobalFloat(copyBicubicId, bicubicRescaling ? 1f : 0f);
DrawFinal(finalResultId, Pass.FinalRescale);
双线性和双三次重新缩放;渲染缩放 0.25
双线性和双三次重新缩放;渲染缩放 0.25
双线性和双三次重新缩放;渲染缩放 0.25

仅双三次上采样

双三次重新缩放在上采样时总是提高质量,但在下采样时差异不太明显。对于渲染缩放 2,它总是无用的,因为每个最终像素是四个像素的平均值,与双线性插值完全相同。因此,让我们将 BufferSettings 中的切换替换为三种模式之间的选择:关闭、仅上采样和上下采样。

1
2
3
public enum BicubicRescalingMode { Off, UpOnly, UpAndDown }

public BicubicRescalingMode bicubicRescaling;

更改 PostFXStack 中的类型以匹配。

1
2
3
4
5
6
CameraBufferSettings.BicubicRescalingMode bicubicRescaling;
....
public void Setup (
    ....
    CameraBufferSettings.BicubicRescalingMode bicubicRescaling
) { .... }

最后更改 DoColorGradingAndToneMapping,使双三次采样仅用于上下模式,或者如果我们使用降低的渲染缩放则用于仅上采样模式。

1
2
3
4
5
bool bicubicSampling =
    bicubicRescaling == CameraBufferSettings.BicubicRescalingMode.UpAndDown ||
    bicubicRescaling == CameraBufferSettings.BicubicRescalingMode.UpOnly &&
    bufferSize.x < camera.pixelWidth;
buffer.SetGlobalFloat(copyBicubicId, bicubicSampling ? 1f : 0f);
仅对上采样进行双三次重新缩放
仅对上采样进行双三次重新缩放

下一个教程FXAA

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