2020年7月8日 星期三

Scriptable Render Pipeline (2)


  • 前言
在上一個章節,

我完成了一個簡單只顯示背景天空的 Scriptable Render Pipeline (之後簡稱 SRP),

在本章節,將接續處理前一章節沒有討論的部分,也就是物件渲染的處理。


  • Culling
在渲染物件之前,先從效能方面來考慮,我會希望能夠避開渲染不再畫面內的物件,

因此,我要做的第一步就是找出這些畫面外的物件,並過濾掉他們。

由於物件是否在畫面外是根據攝影機位置,所以我可以從攝影機取得判斷 Culling 的參數。

void CameraRender(ScriptableRenderContext context, Camera camera)
{
    ScriptableCullingParameters cullingParameters;
    camera.TryGetCullingParameters(out cullingParameters);
}

根據 Unity Documentation 的說明,我可以用 TryGetCullingParameters 的回傳值,

判斷攝影機是否作用中。

如果回傳 false,我可以不做渲染來節省效能。

void CameraRender(ScriptableRenderContext context, Camera camera)
{
    ScriptableCullingParameters cullingParameters;
    if (!camera.TryGetCullingParameters(out cullingParameters))
            return;
}


取得了判斷 Culling 的參數後,我就可以對物件作 Culling。

在 Scriptable Render Pipeline (1) 裡,

提到了所有物件的資料都被封裝在 ScriptableRenderContext 這個結構裡。

實際上,ScriptableRenderContext 提供了 Cull  這個函數,幫助我做物件遞除。

所以接下來我要做的事就是呼叫 Cull 函數去得到物件 Culling 的結果。

void CameraRender(ScriptableRenderContext context, Camera camera)
{
    ScriptableCullingParameters cullingParameters;
    if (!camera.TryGetCullingParameters(out cullingParameters))
        return;

    CullingResults cullingResults = context.Cull(ref cullingParameters);
}


如果有看過我在 3D 渲染基礎 這篇文章提到的內容,應該會知道 GPU 在做渲染時,

也會做一次物件裁切(clip),既然 GPU 也做了一次,為什麼我又在這裡做一次?

這個原因可以用下圖說明



由於渲染物件的資料在CPU,

若要交由GPU去做 Clipping 就需要將物件資料由CPU傳送到GPU。

這個動作比起直接在CPU做 Culling 多了一個傳送資料的過程。

然而效能的瓶頸往往都是發生在資料傳輸的這個階段。

因此,在CPU就決定物件是否被渲染會是一個比較好的選擇。


  • 渲染順序


決定好那些物件要被渲染時,接下來就是要考慮物件渲染順序。

在這裡,我先只考慮渲染不透明的物件。

和實作 Culling 時一樣,先從效能開始考慮。

在談到效能時,相信時常會聽到 Overdarw 這個名詞,

Overdraw 簡單來說,就是在同一個 frame 裡,對畫面的某一點 pixel 做了兩次以上的渲染。

試想在畫面上,每一個 pixel 只會看到一個顏色,

因此可以合理的認為每一個 pixel 只會做一次渲染。

當某一點 pixel 做了兩次以上的渲染,這就表示除了最後一次的渲染以外都是沒有作用的。

因為這些渲染結果都不會出現在畫面上。


那麼在什麼情況下會出現 Overdraw,當渲染順序不正確時,就有可能發生

假設我渲染兩個 Sphere,一個紅色、一個藍色,

紅色在後面(離攝影機較遠),藍色在前面(離攝影機較近)。

我設定渲染順序為:從離攝影機遠的到離攝影機近的

這時開啟 Frame Debugger 觀察可以看到下圖結果



其中,粉紅色的部分做了兩次渲染 (先紅在藍)



接著,我試著把渲染的順序做了變更,

將渲染順序改成:從離攝影機近的到離攝影機遠的。

同樣地,再開啟 Frame Debugger 觀察可以看到下圖結果



兩個 Sphere 的重疊部分因為 Z-buffer test 不會再做一次渲染,

因此在這個情況下畫面每個 pixel 都最多只做一次渲染。

效能上自然比前者來的好。


回到 SRP ,基於上述討論結果,我希望能設定渲染順序是由前往後。

Unity 提供了 SortingSettings 結構讓我設定渲染順序

void DrawOpaque(ScriptableRenderContext context, Camera camera)
{
    SortingSettings sortingSettings = new SortingSettings(camera);
    sortingSettings.criteria = SortingCriteria.CommonOpaque;
}

其中, SortingCriteria.CommonOpaque 就是指定物件的渲染是由前往後。



  • 使用指定的 Shader 進行渲染


如果有打開過 Unity 內建的 Standard Shader,一定會發現裡面寫了密密麻麻的內容,

再仔細觀察,會發現這些內容被區分成數個 Pass。

事實上,Unity並不會一次執行 Shader 內的所有 Pass,

而是視 Unity 環境的 Render Path 設定(ex. forward 或 Deferred),

在適當的階段,執行指定的 Pass。


在這裡,我想將這個機制導入客製的 SRP 裡,

為了達到這個目的,我需要執行兩個動作


  1. 為 Shader 內的 Pass 命名,讓 SRP 能夠識別 Shader 內的所有 Pass。
  2. 告訴 SRP 執行 Shader 內的哪個 Pass。


首先,要將 Shader 內的 Pass 命名, 可以在 Pass 內 Tags 的 LightMode 欄位設定。

Shader "DemoPipelineShader"
{
    Properties
    {
        // 省略
    }
    SubShader
    {
        Pass
        {
            Tags { "LightMode"="DemoPipelineOnly" }
            CGPROGRAM
            // 省略
            ENDCG
        }
    }
}

在上面,我指定 Pass 的名稱為 DemoPipelineOnly。

接下來,我要告訴 SRP 讓所有可被渲染物件使用的 Shader,

都只執行 DemoPipelineOnly 這個 Pass。

Unity 提供了 DrawingSettings 結構來進行這樣的設定。

void DrawOpaque(ScriptableRenderContext context, Camera camera)
{
    SortingSettings sortingSettings = new SortingSettings(camera);
    sortingSettings.criteria = SortingCriteria.CommonOpaque;

    DrawingSettings drawingSettings = new DrawingSettings();
    drawingSettings.SetShaderPassName(0, new ShaderTagId("DemoPipelineOnly"));
    drawingSettings.sortingSettings = sortingSettings;
}

到了這裡,只有叫做 DemoPipelineOnly 的 Pass 才會被渲染。

這代表著如果物件使用的 Shader 沒有  DemoPipelineOnly 這個 Pass ,

就算物件在可視範圍內,也不會被渲染。

如果, Shader 內的 Pass 沒有指定名稱,那這個 Pass 會被預設命名為 SRPDefaultUnlit。



  • 過濾可視物件


完成了渲染順序和渲染內容的設定後,接下來就是要對這些可被渲染的物體進行條件過濾。

為什麼需要作條件過濾?

在使用 Unity 時,為了達到某些效果,會需要用到兩個以上的 Camera 去進行渲染。

這時會希望某些物件,只由特定的 Camera 執行渲染。

(常見的有 UI,或一些不受濾鏡影響的特效。)

因此希望可以透過條件過濾去指定物件被哪個 Camera 渲染。

常見的方法是設定物件 Layer, 然後用 Camera 的 cullingMask 參數做過濾。

Unity 提供了 FilteringSettings 結構來進行這樣的設定

void DrawOpaque(ScriptableRenderContext context, Camera camera)
{
    SortingSettings sortingSettings = new SortingSettings(camera);
    sortingSettings.criteria = SortingCriteria.CommonOpaque;

    DrawingSettings drawingSettings = new DrawingSettings();
    drawingSettings.SetShaderPassName(0, new ShaderTagId("DemoPipelineOnly"));
    drawingSettings.sortingSettings = sortingSettings;

    FilteringSettings filteringSettings = new FilteringSettings();
    filteringSettings.renderQueueRange = new RenderQueueRange(0, 2500);
    filteringSettings.renderingLayerMask = 1;
    filteringSettings.layerMask = camera.cullingMask;
}

其中, filteringSettings.layerMask 就直接引用 Camera 的 cullingMask 設定,

作為物件渲染的過濾條件。

附帶一提的是,我可以不只用物件的 layer 做條件判斷,

我也可以使用物件使用 shader 的 render queue 範圍來判斷。

在上面的程式碼,我設定 render queue 的範圍要在 0 ~ 2500 之間,才能被渲染。

這提供了更多的彈性,讓我設定客製渲染流程。



  • 渲染物件


當完成了上述所有設定,就可以執行物件渲染的動作了。

和天空盒一樣,物件渲染也是被包裝在 ScriptableRenderContext 結構內。

void DrawOpaque(ScriptableRenderContext context, Camera camera)
{
    SortingSettings sortingSettings = new SortingSettings(camera);
    sortingSettings.criteria = SortingCriteria.CommonOpaque;

    DrawingSettings drawingSettings = new DrawingSettings();
    drawingSettings.SetShaderPassName(0, new ShaderTagId("DemoPipelineOnly"));
    drawingSettings.sortingSettings = sortingSettings;

    FilteringSettings filteringSettings = new FilteringSettings();
    filteringSettings.renderQueueRange = new RenderQueueRange(0, 2500);
    filteringSettings.renderingLayerMask = 1;
    filteringSettings.layerMask = camera.cullingMask;

    context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
}

上面程式碼,就是渲染物件所需要的基礎設定。

我把這段程式碼和 Scriptable Render Pipeline (1) 的部分做結合。

protected override void Render(ScriptableRenderContext context, Camera[] cameras)
{        
    for (int i = 0; i < cameras.Length; ++i)
    {
        CameraRender(context, cameras[i]);
    }

    context.Submit();
}

void CameraRender(ScriptableRenderContext context, Camera camera)
{
    ScriptableCullingParameters cullingParameters;
    if (!camera.TryGetCullingParameters(out cullingParameters))
        return;

    cullingResults = context.Cull(ref cullingParameters);

    ClearRenderBuffer(cmdBuff, context, camera);
    context.SetupCameraProperties(camera);

    if (camera.clearFlags == CameraClearFlags.Skybox)
        context.DrawSkybox(camera);

    DrawOpaque(context, camera);
}

OK,畫面可以正常顯示,使用的 Shader 也可以正常執行。

看似正常,但其實有一個地方需要再改進。

在這裡,我是先呼叫 DrawSkybox 渲染天空盒之後,再呼叫 DrawOpaque 渲染不透明物件。

但是還記得我在本章節渲染順序裡,提到關於 Overdraw 的內容嗎?

這樣的執行順序會導致不透明物件和天空盒重疊的部分產生 Overdraw。

所以,為了避免 Overdraw ,我需要將呼叫 DrawSkybox 和 DrawOpaque 的順序顛倒,

才能得到更好的效能。

因此,本章節最後的程式碼如下所示:

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering;

public class DemoPipeline : UnityEngine.Rendering.RenderPipeline
{
    CommandBuffer cmdBuff;

    CullingResults cullingResults;

    public DemoPipeline()
    {
        cmdBuff = new CommandBuffer();
    }

    protected override void Render(ScriptableRenderContext context, Camera[] cameras)
    {        
        for (int i = 0; i < cameras.Length; ++i)
        {
            CameraRender(context, cameras[i]);        
        }

        context.Submit();
    }

    void CameraRender(ScriptableRenderContext context, Camera camera)
    {
        ScriptableCullingParameters cullingParameters;
        if (!camera.TryGetCullingParameters(out cullingParameters))
            return;

        cullingResults = context.Cull(ref cullingParameters);

        ClearRenderBuffer(cmdBuff, context, camera);
        context.SetupCameraProperties(camera);

        DrawOpaque(context, camera);

        if (camera.clearFlags == CameraClearFlags.Skybox)
            context.DrawSkybox(camera);        
    }

    void ClearRenderBuffer(CommandBuffer cmd, ScriptableRenderContext context, Camera cam)
    {
        CameraClearFlags clearFlags = cam.clearFlags;

        bool isClearDepth = true;
        bool isClearColor = true;

        switch (clearFlags)
        {
            case CameraClearFlags.Depth:
                isClearColor = false;
                break;

            case CameraClearFlags.Nothing:
                isClearDepth = false;
                isClearColor = false;
                break;
        }

        cmd.ClearRenderTarget(isClearDepth, isClearColor, cam.backgroundColor);
        context.ExecuteCommandBuffer(cmd);
        cmd.Clear();
    }

    void DrawOpaque(ScriptableRenderContext context, Camera camera)
    {
        SortingSettings sortingSettings = new SortingSettings(camera);
        sortingSettings.criteria = SortingCriteria.CommonOpaque;

                
        DrawingSettings drawingSettings = new DrawingSettings();
        drawingSettings.SetShaderPassName(0, new ShaderTagId("DemoPipelineOnly"));
        drawingSettings.sortingSettings = sortingSettings;
        drawingSettings.enableInstancing = true;
                
        FilteringSettings filteringSettings = new FilteringSettings();
        filteringSettings.renderQueueRange = new RenderQueueRange(0, 2500);
        filteringSettings.renderingLayerMask = 1;
        filteringSettings.layerMask = camera.cullingMask;

        context.DrawRenderers(cullingResults, ref drawingSettings, ref filteringSettings);
    }
}



  • 結論
本章節簡單的介紹如何撰寫一個渲染不透明物件的客製 SRP,

在這裡,我特別強調不透明物件是因為這是針對 overdraw 的考量。

但是如果要渲染透明物件的話,overdraw 將是無法避免得一個問題。

這也就是透明物件對效能造成影響的主要原因。

但遺憾的是,一般遊戲很難不用到透明物件的。

如何有效在畫面效果和效能間取得平衡,永遠是遊戲製作上的一個必考題。

在本章節,不討論如何撰寫支援透明物件渲染的客製 SRP。

因為作法和渲染不透明一樣,只是需要注意執行順序要在渲染天空盒之後,

渲染的物件順序要由後往前(離攝影機遠到離攝影機近),剛好跟不透明渲染順序相反。




沒有留言:

張貼留言