Unity3D CustomSRP[譯].1.自定義渲染管線[Custom Render Pipeline]

Custom Render Pipeline(自定義渲染管線)

——控制渲染


本節(jié)內(nèi)容

  • 創(chuàng)建一個渲染管線資源和實例
  • 渲染相機的視圖
  • 執(zhí)行裁剪、過濾及排序
  • 分離非透明、透明、以及無效的通道
  • 使用多個相機



這是一個關(guān)于如何創(chuàng)建一個Custom SRP的系列教程的第一個部分,它包含了一個最基本的渲染管線的創(chuàng)建,我們會在之后的章節(jié)中陸續(xù)擴展它。

這個教程使用的Unity版本是2019.2.6f1.



使用自定義渲染管線進行渲染

(ps:文章總被吞…最后偶然看到可能會被吞的一些詞兒…嘗試改了點但有些意思感覺不到位~)


其他的SRP系列呢?
.
我有另一個涵蓋了可編程渲染管線的教程,但它使用的SRP的API只適用于Unity2018版本,而這個系列則為Unity2019及之后的版本。本系列采用一種不同的、更貼近現(xiàn)在技術(shù)潮流的方法,但有許多相同的主題都會被涵蓋到。如果你等不及本系列教程的更新節(jié)奏的話,Unity2018系列教程仍舊對工作很有幫助,直到這個系列教程的更新能夠趕上進度。


1. 一個新的渲染管線(A new Render Pipeline)

為了渲染任何東西,Unity需要去決定繪制什么形狀,在什么地方,什么時候,使用了什么設(shè)置——這使得整個渲染過程變得非常復(fù)雜,其復(fù)雜程度主要取決于有多少東西產(chǎn)生了影響:燈光、陰影、透明度、圖像效果、體積效果……這些都必須按照正確的順序處理,才能得到最終的圖像——這就是渲染管線所做的。

在過去,Unity只支持一些內(nèi)置的渲染方式(built-in RP)。在Unity2018版本中則引入了可編程渲染管線(SRP)——它使得我們實現(xiàn)自己想要的一切成為了一種可能,在此同時卻仍然能夠依賴于Unity本身的一些基礎(chǔ)功能例如裁剪。在Unity2018中還基于這種新的方式添加了兩種試驗性質(zhì)的渲染管線:輕量級渲染管線(LWRP,Unity 2019.3 后變更為URP)和高清渲染管線(HDRP)。而在Unity2019.3LWRP就不再是試驗性質(zhì)的了,更名為通用渲染管線(URP)。

URP注定要取代當(dāng)前遺留的管線從而作為默認(rèn)選項。原因是,它是一個能適應(yīng)大多數(shù)選擇的渲染管線,而且能很輕松的客制化定制。本系列教程將從頭開始創(chuàng)建一個完整的渲染通道,而不是自定義一個如上所述的渲染通道。

這個教程將以最簡單的渲染管線為基礎(chǔ),使用正向渲染繪制無光照的幾何形狀。如果這一步完成了,我們就可以在之后的教程中逐漸擴展我們的渲染管線,例如添加照明、陰影、采用不同的渲染方法、和更高級的功能和特性。

1.1 工程設(shè)置(Project Setup)

在Unity 2019.2.6或更高版本中創(chuàng)建一個新的3D項目。我們將創(chuàng)建自己的管線,所以不要選擇任何一個項目模板。當(dāng)項目打開后,您可以到包管理器里刪除不需要的所有包。在本教程中,我們將只使用Unity UI包來嘗試?yán)L制UI,所以你可以保留那個包。

我們將專門在線性顏色空間中工作,但Unity 2019.2仍然使用伽馬空間作為默認(rèn)值。通過Edit/Project Settings/Player/Other Settings進入設(shè)置,切換Color Spacelinear

顏色空間設(shè)置為線性空間

創(chuàng)建一些不同的對象來填充默認(rèn)場景,例如使用標(biāo)準(zhǔn)的、無光照的不透明的或者透明的材質(zhì)。Unlit/Transparent著色器需要一個紋理才生效,所以這里提供一個UV球面映射圖。

UV球面映射透明貼圖

我在測試場景中放置了幾個立方體,它們都是不透明的。紅色的使用標(biāo)準(zhǔn)著色器(Standard shader),而綠色和黃色的使用了采用Unlit/Color著色器的材質(zhì)。藍色的球體使用標(biāo)準(zhǔn)著色器,渲染模式設(shè)置為透明(Transparent),而白色的球體使用Unlit/Transparent著色器。

測試場景


1.2 管線資源(Pipeline Asset)

目前,Unity使用了默認(rèn)的渲染管線,我們需要用自定義渲染管線來替換它,但我們首先要為它創(chuàng)建一個資源。我們將采用Unity為了URP使用的大致相同的文件夾結(jié)構(gòu)。創(chuàng)建如下圖所示的文件夾結(jié)構(gòu),并在Runtime文件夾下創(chuàng)建一個名為CustomRenderPipelineAsset的c#腳本。

文件夾結(jié)構(gòu)

這個腳本必須繼承于UnityEngine.Rendering命名空間下的RenderPipelineAsset對象。

using UnityEngine;
using UnityEngine.Rendering;

public class CustomRenderPipelineAsset : RenderPipelineAsset {}

RenderPipelineAsset的主要作用是讓Unity能夠獲得一個負(fù)責(zé)渲染的管線對象實例。CustomRenderPipelineAsset本身只是一個句柄和存儲設(shè)置的工具。我們還沒有進行任何的設(shè)置,所以我們接下來要做的就是給Unity一個獲取渲染管線對象實例的方法。這就需要通過重寫RenderPipelineAsset里定義的抽象方法CreatePipeline(),該方法應(yīng)該返回一個RenderPipeline實例。但是我們現(xiàn)在還沒有定義自定義的RenderPipeline類,所以先返回一個null。

CreatePipeline()是用受保護的訪問修飾符protected定義的,這意味著只有定義和擴展RenderPipelineAsset類才能訪問它。

protected override RenderPipeline CreatePipeline () {
    return null;
}

現(xiàn)在我們需要將CustomRenderPipelineAsset添加到項目中。要做到這一點,可以在CustomRenderPipelineAsset開頭添加一個CreateAssetMenu屬性。

[CreateAssetMenu]
public class CustomRenderPipelineAsset : RenderPipelineAsset { … }

這將在Asset/Create菜單中添加一個選項, 讓我們把它放到Rendering子菜單中。我們將CreateAssetMenumenuName屬性設(shè)置為Rendering/Custom Render Pipeline。可以在CreateAssetMenu之后的圓括號內(nèi)直接設(shè)置這個屬性。

[CreateAssetMenu(menuName = "Rendering/Custom Render Pipeline")]
public class CustomRenderPipelineAsset : RenderPipelineAsset { … }

使用新的菜單項將CustomRenderPipelineAsset添加到項目中,然后轉(zhuǎn)到Edit->Project settings->Graphics設(shè)置中,并在Scriptable Render Pipeline settings下選擇它。

Custom RP被選中

替換默認(rèn)的渲染管線改變了一些東西。首先,許多選項從Graphics設(shè)置中消失了,這在信息面板中也提示了。其次,我們禁用了默認(rèn)的渲染管線,而沒有提供一個有效的替換選擇,因此它不再渲染任何內(nèi)容。游戲窗口、場景窗口和材質(zhì)預(yù)覽不再具備任何功能。如果你打開幀調(diào)試器(Window/Analysis/Frame Debugger)并啟用它,你會發(fā)現(xiàn)在游戲窗口中沒有繪制任何內(nèi)容。


1.3 渲染管線實例(Render Pipeline Instance)

創(chuàng)建一個CustomRenderPipeline類,并將其腳本文件放在與CustomRenderPipelineAsset相同的文件夾中。這將是我們的CustomRenderPipelineAsset返回的渲染管線實例類型,因此它必須繼承自RenderPipeline。

using UnityEngine;
using UnityEngine.Rendering;

public class CustomRenderPipeline : RenderPipeline {}

RenderPipeline定義了一個受保護的抽象方法Render,我們必須重寫它來創(chuàng)建一個具體的管線實例。它有兩個參數(shù):ScriptableRenderContextCamera[],我們暫時將該方法內(nèi)部保留為空的。

protected override void Render (ScriptableRenderContext context, Camera[] cameras) {}

使CustomRenderPipelineAsset.CreatePipeline()返回一個新的CustomRenderPipeline實例。這將為我們提供一個有效且實用的管線,盡管它到現(xiàn)在為止還沒有渲染任何東西。

protected override RenderPipeline CreatePipeline () {
    return new CustomRenderPipeline();
}


2. 渲染(Rendering)

Unity會在每一幀中調(diào)用一次渲染管線實例中的Render()方法。它傳遞一個ScriptableRenderContext結(jié)構(gòu),提供到本地引擎的鏈接,我們可以使用這個接口進行渲染。它還會傳遞一個相機數(shù)組,因為場景中可能有多個激活的相機。它是RenderPipeline提供的負(fù)責(zé)按順序渲染所有相機的一個接口。

2.1 相機渲染(Camera Renderer)

每個相機都是獨立渲染的, 因此,我們不會讓CustomRenderPipeline渲染所有的相機,而是把這個職責(zé)交給單獨提供提供相機渲染功能的一個新類。我們將它命名為CameraRenderer,并給它提供一個帶有ScriptableRenderContextCamera類型參數(shù)的公共的Rendert()方法。為了方便起見,我們將這些參數(shù)存儲在字段中。

using UnityEngine;
using UnityEngine.Rendering;

public class CameraRenderer {
    ScriptableRenderContext context;
    Camera camera;
    public void Render (ScriptableRenderContext context, Camera camera) {
        this.context = context;
        this.camera = camera;
    }
}

CustomRenderPipeline在創(chuàng)建渲染器時創(chuàng)建一個CameraRenderer 實例,然后使用Render()方法來循環(huán)渲染所有的相機。

CameraRenderer renderer = new CameraRenderer();

protected override void Render (ScriptableRenderContext context, Camera[] cameras){
    foreach (Camera camera in cameras) {
        renderer.Render(context, camera);
    }
}

我們的相機渲染器大致相當(dāng)于URP的可編程渲染器。這種方法將使未來支持每個相機的不同的渲染方法變得簡單,例如一個用于第一人稱視角,一個用于3D地圖疊加,或者正向渲染和延遲渲染。但是僅現(xiàn)在而言,我們將以相同的方式渲染所有的相機。

2.2 繪制天空盒(Drawing the Skybox)

CameraRenderer.Render()的工作是繪制相機可以看到的所有幾何圖形。提供一個單獨的DrawVisibleGeometry()方法使該目標(biāo)和代碼干凈整潔。首先,我們將讓這個方法繪制默認(rèn)的天空盒(SkyBox),這可以通過調(diào)用ScriptableRenderContext中提供了Camera作為參數(shù)的DrawSkybox()方法。

public void Render (ScriptableRenderContext context, Camera camera) {
    this.context = context;
    this.camera = camera;

    DrawVisibleGeometry();
}

void DrawVisibleGeometry () {
    context.DrawSkybox(camera);
}

這些操作還不會使天空盒出現(xiàn),這是因為我們向context發(fā)出的命令被緩沖了。我們必須通過在context上調(diào)用submit()來提交排序好的指令用以執(zhí)行。讓我們在一個單獨的Submit()方法中做這件事,在DrawVisibleGeometry()之后調(diào)用它。

public void Render (ScriptableRenderContext context, Camera camera) {
    this.context = context;
    this.camera = camera;

    DrawVisibleGeometry();
    Submit();
}

void Submit () {
    context.Submit();
}

天空盒最終出現(xiàn)在游戲(Game)和場景(Scene)窗口中。當(dāng)啟用天空盒時,你還可以在幀調(diào)試器(Frame Debugger)中看到有關(guān)它的條目, 它被列為Camera.RenderSkybox,在它下面有一個單獨的Draw Mesh項,它代表實際的繪制調(diào)用(DrawCall)。這對應(yīng)于游戲窗口的渲染。幀調(diào)試器不顯示其他窗口中的繪制項。

天空盒被繪制

注意,相機的朝向目前并不影響天空盒的渲染方式。我們將相機傳遞給DrawSkybox,但它只決定是否應(yīng)該繪制天空盒,這是通過相機的clear flags來控制的。

為了正確地渲染天空盒和整個場景,我們必須建立視圖投影矩陣(view-projection matrix)。這個變換矩陣將相機的位置和方向(視圖矩陣)與相機的透視或投影矩陣結(jié)合在一起。它在著色器(Shader)中被稱為unity_MatrixVP,是繪制幾何圖形時使用的著色器屬性之一。當(dāng)繪制調(diào)用被選中時,你可以在幀調(diào)試器的ShaderProperties部分中查看到這個矩陣。

此時,unity_MatrixVP矩陣總是不變的。我們必須通過SetupCameraProperties方法,將相機的屬性應(yīng)用到ScriptableRenderContext中。這個步驟裝配了這個矩陣和其他一些屬性。在調(diào)用DrawVisibleGeometry()之前,新建一個單獨的Setup()方法來執(zhí)行這個操作。

public void Render (ScriptableRenderContext context, Camera camera) {
    this.context = context;
    this.camera = camera;

    Setup();
    DrawVisibleGeometry();
    Submit();
}

void Setup () {
    context.SetupCameraProperties(camera);
}

正確對齊的天空盒

2.3 指令緩沖區(qū)(Command Buffers)

ScriptableRenderContext延遲了實際的渲染,直到我們提交它。在此之前,我們需要對它進行配置,并向它添加命令以供以后執(zhí)行。有些指令(如繪制天空盒)可以通過專用接口執(zhí)行,但其他指令必須通過單獨的命令緩沖區(qū)間接發(fā)出。我們需要這樣的緩沖來繪制場景中的其他幾何體。

為了獲得一個緩沖區(qū),我們必須創(chuàng)建一個新的CommandBuffer實例對象。我們只需要一個緩沖區(qū),所以為CameraRenderer創(chuàng)建一個默認(rèn)的緩沖區(qū),并在字段中存儲對它的引用。然后為這個緩沖區(qū)命名,這樣我們就可以在幀調(diào)試器中識別它。這樣就可以實現(xiàn)相機渲染功能了。

const string bufferName = "Render Camera";

CommandBuffer buffer = new CommandBuffer {
    name = bufferName
};


對象初始化器語法是如何工作的?
.
這就好像我們寫了buffer.name = bufferName;作為調(diào)用構(gòu)造函數(shù)后的單獨語句。但在創(chuàng)建一個新的對象時,可以將一個代碼塊附加到構(gòu)造函數(shù)的調(diào)用中。然后,你可以在這個代碼塊中設(shè)置對象的字段和屬性,而不必顯式地引用對象的實例。它明確指出,實例應(yīng)該在設(shè)置了這些字段和屬性之后才被使用。除此之外,它還允許只允許一條語句的情況下進行初始化,例如,我們在這里使用的字段初始化,而不需要帶有許多參數(shù)變體的構(gòu)造函數(shù)。
.
請注意,我們省略了調(diào)用構(gòu)造函數(shù)時的空的形參列表,在使用對象初始化器語法時這樣做是可以的。



我們可以使用命令緩沖區(qū)來注入分析器采樣,這些采樣將同時顯示在分析器(Profiler)和幀調(diào)試器(Frame Debugger)中。這是通過在適合的地方調(diào)用BeginSampleEndSample來完成的,在我們的例子中,這是在Setup()和`Submit()方法的開始位置。必須為這兩個方法提供相同的采樣名,我們將使用緩沖區(qū)的名字。

void Setup () {
    buffer.BeginSample(bufferName);
    context.SetupCameraProperties(camera);
}

void Submit () {
    buffer.EndSample(bufferName);
    context.Submit();
}

要執(zhí)行緩沖區(qū),需要使用緩沖區(qū)作為參數(shù)在context中調(diào)用ExecuteCommandBuffer()方法。這將從緩沖區(qū)中復(fù)制命令,但這里不會清除它,如果我們想重用它,我們必須在之后顯式地調(diào)用清除操作, 因為執(zhí)行和清理總是一起完成的,所以添加一個同時做這兩件事的方法就顯得很方便。

void Setup () {
    buffer.BeginSample(bufferName);
    ExecuteBuffer();
    context.SetupCameraProperties(camera);
}

void Submit () {
    buffer.EndSample(bufferName);
    ExecuteBuffer();
    context.Submit();
}

void ExecuteBuffer () {
    context.ExecuteCommandBuffer(buffer);
    buffer.Clear();
}

Camera.RenderSkyBox采樣現(xiàn)在被嵌套在Render Camera中。

Render Camera 采樣

2.4 清除渲染目標(biāo)(Clearing the Render Target)

無論我們繪制什么,最終都會渲染到相機的渲染目標(biāo)(RenderTarget)中,它可以是默認(rèn)的幀緩沖,但也可以是渲染紋理。上一幀被繪制到目標(biāo)的圖像仍然存儲在那里,這可能會干擾我們現(xiàn)在這一幀要渲染的圖像。為了保證正確的渲染,我們必須清除渲染目標(biāo)以去除它的舊內(nèi)容。這是通過在命令緩沖區(qū)上調(diào)用ClearRenderTarget()方法來完成的,該一步應(yīng)該放到Setup()方法中。

CommandBuffer.ClearRenderTarget()方法至少需要三個參數(shù)。前兩個標(biāo)志著是否應(yīng)該清除深度和顏色數(shù)據(jù),這兩個都被設(shè)為true。第三個參數(shù)是用作清除的顏色,我們將使用Color.clear。

void Setup () {
    buffer.BeginSample(bufferName);
    buffer.ClearRenderTarget(true, true, Color.clear);
    ExecuteBuffer();
    context.SetupCameraProperties(camera);
}

清理操作-嵌套的采樣

幀調(diào)試器中現(xiàn)在出現(xiàn)了一個Draw GL項用于顯示清除操作,它嵌套在一個額外的Render Camera項中。發(fā)生這種情況是因為ClearRenderTarget用命令緩沖區(qū)的名稱囊括了示例中的清除操作。在開始我們自己的采樣之前,我們可以通過清理操作來清除多余的嵌套。這將使兩個相鄰的Render Camera采樣被合并。

void Setup () {
    buffer.ClearRenderTarget(true, true, Color.clear);
    buffer.BeginSample(bufferName);
    // buffer.ClearRenderTarget(true, true, Color.clear);
    ExecuteBuffer();
    context.SetupCameraProperties(camera);
}

清理操作-沒有嵌套

Draw GL項表示使用Hidden/InternalClear著色器繪制一個全屏四邊形,該著色器被寫入渲染目標(biāo),這不是清除操作最有效的方式。使用這種方法是因為我們需要在設(shè)置相機屬性之前執(zhí)行清理操作。如果我們交換這兩個步驟的順序,我們就得到了快速清除的方法。

void Setup () {
    context.SetupCameraProperties(camera);
    buffer.ClearRenderTarget(true, true, Color.clear);
    buffer.BeginSample(bufferName);
    ExecuteBuffer();
    //context.SetupCameraProperties(camera);
}

正確清理

現(xiàn)在我們可以看到Clear (color+Z+stencil),這表明顏色緩沖區(qū)和深度緩沖區(qū)都被清理干凈了。Z表示深度緩沖區(qū),模板數(shù)據(jù)(stencil)是同一緩沖區(qū)的一部分。

2.5 裁剪(Culling)

我們現(xiàn)在可以看到天空盒,但看不到我們放入場景中的任何物體。我們將只渲染那些對相機可見的物體,而不是繪制每個對象。我們從場景中所有帶有Renderer組件的對象開始,然后剔除那些處于相機視圖錐體(view frustum)外的對象。

想要知道哪些東西可以被剔除,我們跟蹤多個相機的設(shè)置和矩陣,我們可以使用ScriptableCullingParameters結(jié)構(gòu)。我們可以調(diào)用cameraTryGetCullingParameters()方法,而不是自己填充這個結(jié)構(gòu)的數(shù)據(jù)。它返回參數(shù)是否可以成功裁剪,如果失敗的話可以避免相機設(shè)置后邊的步驟(return)。為了獲得參數(shù)數(shù)據(jù),我們必須把它作為輸出參數(shù)提供,在前面添加out。在一個單獨的Cull()方法中執(zhí)行這些操作,該方法返回true或'false'。

bool Cull () {
    ScriptableCullingParameters p
    if (camera.TryGetCullingParameters(out p)) {
        return true;
    }
    return false;
}


我們?yōu)槭裁匆獙懸粋€out關(guān)鍵字?
.
當(dāng)一個結(jié)構(gòu)體類型的參數(shù)被定義為輸出參數(shù)時,它的表現(xiàn)就像一個對象的引用,指向該參數(shù)所在的內(nèi)存堆棧的位置。當(dāng)方法內(nèi)部更改了參數(shù)時,它將影響該值,而不僅僅是一個副本拷貝。
.
out關(guān)鍵字告訴我們,該方法負(fù)責(zé)正確設(shè)置參數(shù),替換之前的值。
.
Try-get方法是表示成功或失敗以及產(chǎn)生結(jié)果的常用方法。



當(dāng)作為輸出參數(shù)使用時,可以將變量聲明內(nèi)聯(lián)到參數(shù)列表中,所以讓我們這樣做:

bool Cull () {
    //ScriptableCullingParameters p
    if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {
        return true;
    }
    return false;
}

Render()方法中調(diào)用Setup()之前調(diào)用Cull(),如果Cull()返回false則中止它(return)。

public void Render (ScriptableRenderContext context, Camera camera) {
    this.context = context;
    this.camera = camera;

    if (!Cull()) {
        return;
    }

    Setup();
    DrawVisibleGeometry();
    Submit();
}

實際的剔除操作是通過調(diào)用ScriptableRenderContext中的Cull()方法來完成的,它會產(chǎn)生一個CullingResults結(jié)構(gòu)體。在Cull()中,如果'camera.TryGetCullingParameters()'返回true,則執(zhí)行上述裁剪操作,并將裁剪結(jié)果存儲在一個字段中。在這種情況下,我們必須將剔除參數(shù)p作為引用參數(shù)傳遞,方法是在參數(shù)前面寫上ref。

CullingResults cullingResults;
…
bool Cull () {
    if (camera.TryGetCullingParameters(out ScriptableCullingParameters p)) {
        cullingResults = context.Cull(ref p);
        return true;
    }
    return false;
}


我們?yōu)槭裁匆?code>ref關(guān)鍵字?
.
ref關(guān)鍵字的工作原理與out類似,只是在方法中不需要為參數(shù)賦值。調(diào)用含有ref參數(shù)方法的地方需要初始化ref參數(shù)的值。因此,它可以用于輸入,也可以用于輸出。
.
在這種情況下,ref關(guān)鍵字被用作優(yōu)化手段,以防止傳遞相當(dāng)大的ScriptableCullingParameters結(jié)構(gòu)體的副本拷貝。它是一個結(jié)構(gòu)體而不是一個對象,這就是另一個優(yōu)化,以防止內(nèi)存分配。


2.6 繪制幾何體(Drawing Geometry)

一旦我們知道哪些東西是可見的,我們就可以繼續(xù)渲染這些東西。這是通過調(diào)用context中的DrawRenderers()方法實現(xiàn)的。將篩選結(jié)果cullingResults作為參數(shù),告訴它使用了哪個渲染器。除此之外,我們還必須提供繪制設(shè)置和過濾設(shè)置。這兩個都是結(jié)構(gòu)體——DrawingSettingsFilteringSettings
——我們將首先使用它們的默認(rèn)構(gòu)造函數(shù),兩者都必須通過引用(ref)傳遞。在繪制天空盒之前,在DrawVisibleGeometry()方法中做這些。

void DrawVisibleGeometry () {
    var drawingSettings = new DrawingSettings();
    var filteringSettings = new FilteringSettings();

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

    context.DrawSkybox(camera);
}

我們現(xiàn)在看不到任何東西,因為我們還必須指出哪種著色器通道(shader pass)是允許的。因為我們在本章節(jié)中只支持無光照著色器,我們必須為SRPDefaultUnlit Pass獲取著色器標(biāo)簽ID,讓我們這樣做并將其緩存到一個靜態(tài)字段中:

static ShaderTagId unlitShaderTagId = new ShaderTagId("SRPDefaultUnlit");

將它作為DrawingSettings構(gòu)造函數(shù)的第一個參數(shù),同時提供一個新的SortingSettings結(jié)構(gòu)體的值。將相機傳遞給SortingSettings的構(gòu)造函數(shù),它將用于確定是采用正交排序還是基于距離的排序。

void DrawVisibleGeometry () {
    var sortingSettings = new SortingSettings(camera);
    var drawingSettings = new DrawingSettings(unlitShaderTagId, sortingSettings);
    …
}

此外,我們還必須指出哪些渲染隊列是允許的。將RenderQueueRange.all傳遞到FilteringSettings構(gòu)造函數(shù)中。這樣我們就包含了所有的隊列。

var filteringSettings = new FilteringSettings(RenderQueueRange.all);

繪制無光照集合體

只有使用無光照著色器的可見對象會被繪制。所有的繪制調(diào)用都在幀調(diào)試器中列出,并在RenderLoop.Draw下分組。透明對象上發(fā)生了一些看起來很奇怪的事情,但先讓我們看看對象的繪制順序。下邊這個由幀調(diào)試器顯示的,您可以通過一個接一個地選擇或使用箭頭鍵來逐步執(zhí)行繪制調(diào)用。

逐步查看幀調(diào)試器列表

繪制順序看起來很雜亂。我們可以通過設(shè)置SortingSettingscriteria屬性強制指定繪制順序,讓我們使用SortingCriteria.CommonOpaquecriteria賦值。

var sortingSettings = new SortingSettings(camera) {
    criteria = SortingCriteria.CommonOpaque
};

CommonOpaque排序.1

CommonOpaque排序.2

對象現(xiàn)在可以基本上從前到后繪制,這對于不透明對象非常理想。如果某些東西被繪制在其他東西的后面,它的隱藏片元可以被跳過,這可以加快渲染速度。CommonOpaque排序選項還考慮到了其他一些標(biāo)準(zhǔn),包括渲染隊列(RenderQueue)和材質(zhì)(Materials)。

2.7 分別繪制不透明和透明幾何體(Drawing Opaque and Transparent Geometry Separately)

幀調(diào)試器向我們展示了繪制透明對象的過程,但是天空盒的繪制會覆蓋掉所有繪制在不透明對象后邊的內(nèi)容。天空盒被繪制在不透明的幾何之后,所以它的所有被覆蓋的片元可以被跳過繪制,但它同時也覆蓋了透明幾何體。這是因為透明著色器不寫入深度緩沖區(qū)(depth buffer)。它們不會覆蓋掉它們后邊的內(nèi)容,因為我們可以透過它們看到后邊的內(nèi)容。解決方法是先畫不透明的物體,然后畫天空盒,然后再畫透明的物體。

通過將FilteringSettingsRenderQueueRange設(shè)置RenderQueueRange.opaque,我們可以將其作為DrawRenderers方法的的一項參數(shù)剔除透明對象。

var filteringSettings = new FilteringSettings(RenderQueueRange.opaque);

然后,在繪制天空盒后再次調(diào)用DrawRenderers()方法。但在這樣做之前,改變renderQueueRangeRenderQueueRange.transparent。還要將criteria更改為SortingCriteria.CommonTransparent并再次設(shè)置drawingSettings.sortingSettings。這改變了透明對象的繪制順序。

context.DrawSkybox(camera);

sortingSettings.criteria = SortingCriteria.CommonTransparent;
drawingSettings.sortingSettings = sortingSettings;
filteringSettings.renderQueueRange = RenderQueueRange.transparent;

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

非透明-天空盒-透明.1

非透明-天空盒-透明.2

為什么渲染順序改變了?
.
因為透明對象不會被寫入深度緩沖區(qū),所以對它們進行前后排序不會帶來性能上的好處。但是當(dāng)透明物體在視覺上互相交叉時,它們必須被從后往前繪制以正確地進行混合。
.
不幸的是,從后往前排序不能保證正確的混合,因為排序是針對每個對象的,并且只基于對象的位置。交叉現(xiàn)象和大型透明對象仍然可能產(chǎn)生不正確的結(jié)果。這個時候可以通過將幾何圖形切割成更小的多個部件來解決。


3. 編輯器下的渲染(Editor Rendering)

我們的渲染管線現(xiàn)在可以正確地繪制了無光照的物體,但我們可以做一些事情來改善在Unity編輯器中使用它的體驗。

3.1 繪制老舊的著色器(Drawing Legacy Shaders)

因為我們的管道只支持無光照的pass,使用不同pass的對象不會被渲染,它們將不可見。雖然這種現(xiàn)象是正確的,但它隱藏了場景中一些對象使用了錯誤的著色器的真相。所以我們還是單獨的渲染它們吧。

如果有人從默認(rèn)的Unity項目開始,然后切換到我們的渲染管線,那么他們可能會在他們的項目中使用錯誤的shader。為了覆蓋所有Unity的默認(rèn)shader,我們必須為Always, ForwardBase, PrepassBase, Vertex, VertexLMRGBMVertexLMpass使用著色器標(biāo)簽id(shaders tag id)。在靜態(tài)數(shù)組中跟蹤這些數(shù)據(jù)。

static ShaderTagId[] legacyShaderTagIds = {
    new ShaderTagId("Always"),
    new ShaderTagId("ForwardBase"),
    new ShaderTagId("PrepassBase"),
    new ShaderTagId("Vertex"),
    new ShaderTagId("VertexLMRGBM"),
    new ShaderTagId("VertexLM")
};

在繪制可見的幾何圖形后邊增加一個單獨的方法,用于繪制所有不支持的shader,只是使用第一次pass通道。因為這些都是無效的pass,結(jié)果無論如何將是錯誤的,所以我們不關(guān)心其他的一些設(shè)置項。我們可以通過FilteringSettings.defaultValue屬性獲得默認(rèn)的過濾設(shè)置。

public void Render (ScriptableRenderContext context, Camera camera) {
    …
    Setup();
    DrawVisibleGeometry();
    DrawUnsupportedShaders();
    Submit();
}
…
void DrawUnsupportedShaders () {
    var drawingSettings = new DrawingSettings(
        legacyShaderTagIds[0], new SortingSettings(camera)
    );
    var filteringSettings = FilteringSettings.defaultValue;
    context.DrawRenderers(
        cullingResults, ref drawingSettings, ref filteringSettings
    );
}

我們可以通過調(diào)用drawingSettingsSetShaderPassName方法并使用繪制順序索引和標(biāo)簽作為參數(shù)來繪制多個pass。從第二個pass開始,對數(shù)組中的所有pass都這樣做,因為我們在構(gòu)造SortingSettings時已經(jīng)設(shè)置了第一個pass。

var drawingSettings = new DrawingSettings(
        legacyShaderTagIds[0], new SortingSettings(camera)
);
for (int i = 1; i < legacyShaderTagIds.Length; i++) {
    drawingSettings.SetShaderPassName(i, legacyShaderTagIds[i]);
}

標(biāo)準(zhǔn)著色器-渲染黑色

使用標(biāo)準(zhǔn)著色器的物體最終渲染出來了,但它們現(xiàn)在是純黑色的,因為我們的渲染管線還沒有為它們設(shè)置所需的shader屬性。

3.2 錯誤材質(zhì)(Error Material)

為了清楚地指出哪些對象使用了不支持的shader,我們將使用Unity的error shader繪制它們。用這個shader作為參數(shù)創(chuàng)建一個新材質(zhì),我們可以通過調(diào)用Shader.Find()方法并將Hidden/InternalErrorShader字符串作為參數(shù)來找到它。通過靜態(tài)字段來緩存這個材質(zhì),這樣我們就不會每幀都創(chuàng)建一個新的材質(zhì)。然后將其分配到drawingSettingsoverrideMaterial屬性。

static Material errorMaterial;
…
void DrawUnsupportedShaders () {
    if (errorMaterial == null) {
        errorMaterial =new Material(Shader.Find("Hidden/InternalErrorShader"));
    }
    var drawingSettings = new DrawingSettings(legacyShaderTagIds[0], new SortingSettings(camera)) {
        overrideMaterial = errorMaterial
    };
    …
}

使用洋紅色渲染錯誤的shader

現(xiàn)在所有的無效對象都是可見的,而且顯而易見這是錯誤的。

3.3 局部類(Partial Class)

在開發(fā)中繪制無效對象很有幫助,但這不適用于已發(fā)布的應(yīng)用。所以讓我們把所有CameraRenderer的僅編輯器有用的代碼放在一個單獨的partial類文件中。首先復(fù)制原來的CameraRenderer.cs腳本資源并將其重命名為CameraRenderer.editor。

一個類-兩個腳本資源

然后將原始的CameraRenderer轉(zhuǎn)換為一個partial類,并從其中移除標(biāo)簽數(shù)組、錯誤材質(zhì)和DrawUnsupportedShaders()方法。

public partial class CameraRenderer { … }


什么是局部類?
.
這是一種將類或結(jié)構(gòu)定義分割為多個部分、存儲在不同文件中的方法。唯一的目的是用于組織代碼結(jié)構(gòu)。典型的用例是將自動生成的代碼與手工編寫的代碼分開。就編譯器而言,它們都是同一個類定義一部分。



清理另一個partial類文件,使它只包含我們從另一個類中刪除的內(nèi)容。

using UnityEngine;
using UnityEngine.Rendering;

partial class CameraRenderer {
    static ShaderTagId[] legacyShaderTagIds = { … };
    static Material errorMaterial;
    void DrawUnsupportedShaders () { … }
}

編輯器的內(nèi)容只需要存在于CameraRender.editor部分中,所以讓它以UNITY_EDITOR宏為條件。

partial class CameraRenderer {
#if UNITY_EDITOR
    static ShaderTagId[] legacyShaderTagIds = { … };
    static Material errorMaterial;
    void DrawUnsupportedShaders () { … };
#endif
}

然而,在這里編譯將失敗,因為另一個partial腳本包含了調(diào)用DrawUnsupportedShaders()方法,但這個方法現(xiàn)在只存在于編輯器中。為了解決這個問題,我們讓這個方法也變?yōu)榫植康姆椒?。為此,我們需要在方法簽名前面加?code>partial關(guān)鍵字,這與抽象方法聲明類似。我們可以在類定義的任何部分中這樣做,所以讓我們把它放在editor部分。完整的方法聲明也必須用partial關(guān)鍵字進行標(biāo)記。

partial void DrawUnsupportedShaders ();
#if UNITY_EDITOR
…
partial void DrawUnsupportedShaders () { … }
#endif

編譯現(xiàn)在成功了,編譯器將剔除所有沒有完整聲明的partial方法的調(diào)用。

我們可以讓無效對象出現(xiàn)在開發(fā)版本中嗎?
.
是的,你可以把條件編譯宏更改為UNITY_EDITOR || DEVELOPMENT_BUILD。那么DrawUnsupportedShaders()也存在于開發(fā)版本中,但還是不會出現(xiàn)于發(fā)布版本中。
.
在本系列中,我將始終分離所有開發(fā)相關(guān)與僅編輯器相關(guān)的內(nèi)容。


3.4 繪制線框(Drawing Gizmos)

目前,無論是在場景窗口還是在游戲窗口中,我們的RP都不會繪制線框,哪怕他是激活的。

沒有g(shù)izmos 的scene窗口

我們可以通過調(diào)用UnityEditor.Handles.ShouldRenderGizmos來檢查gizmos是否應(yīng)該被繪制。如果是的話,我們必須調(diào)用contextDrawGizmos方法,將camera作為它的參數(shù),第二個參數(shù)來指示應(yīng)該繪制哪個GizmoSubset線框類型。GizmoSubset有兩個子類型,分別用于前后圖像效果。由于我們暫時還不支持圖像效果,我們將兩者都調(diào)用。聲明一個新的僅編輯器生效的DrawGizmos()方法,并執(zhí)行此操作。

using UnityEditor;
using UnityEngine;
using UnityEngine.Rendering;

partial class CameraRenderer {
    partial void DrawGizmos ();
    partial void DrawUnsupportedShaders ();
#if UNITY_EDITOR
    …
    partial void DrawGizmos () {
        if (Handles.ShouldRenderGizmos()) {
            context.DrawGizmos(camera, GizmoSubset.PreImageEffects);
            context.DrawGizmos(camera, GizmoSubset.PostImageEffects);
        }
    }
    partial void DrawUnsupportedShaders () { … }
#endif
}

線框應(yīng)該在所有指令結(jié)束后被繪制。

public void Render (ScriptableRenderContext context, Camera camera) {
    …
    Setup();
    DrawVisibleGeometry();
    DrawUnsupportedShaders();
    DrawGizmos();
    Submit();
}

scene窗口中繪制了gizmos


3.4 繪制Unity用戶界面(Drawing Unity UI)

我們需要注意的另一件事是Unity的游戲內(nèi)的用戶界面(UI)。例如,通過GameObject/UI/Button添加一個按鈕來創(chuàng)建一個簡單的UI。它將顯示在游戲窗口,而不是場景窗口。

Game窗口中的UI-Button


為什么我不能創(chuàng)建一個UI按鈕?
.
你需要在你的項目中安裝Unity UI包。


幀調(diào)試器告訴我們,UI是單獨渲染的,而不是由我們的自定義RP渲染的。

frame debugger中的UI項

最起碼,當(dāng)畫布(canvas)組件的渲染模式(Render Mode)設(shè)置為Screen Space - Overlay時是這樣子的,這是默認(rèn)設(shè)置。將其更改為Screen Space - Camera,并將它的Render Camera屬性設(shè)置為主相機(main camera),這將使它成為透明幾何渲染下的一部分。

在frame debugger中,Screen-space-camera UI 的顯示

當(dāng)UI在場景窗口中渲染時,它總是使用World Space模式,這就是為什么它通常會顯得非常大。而且,雖然我們可以通過在scene窗口中編輯UI,但它并不會被繪制。

在scene窗口中的UI始終不可見.png

當(dāng)為scene窗口渲染UI時,我們必須將UI渲染添加到世界中幾何體的渲染中。我們使用camera作為參數(shù),通過調(diào)用ScriptableRenderContext.EmitWorldGeometryForSceneView()來實現(xiàn)這一步。在一個新的僅editor生效的PrepareForSceneWindow()方法中執(zhí)行這個操作。當(dāng)scene cameracameraType屬性等于CameraType.Sceneview時,我們執(zhí)行上述函數(shù)。

partial void PrepareForSceneWindow ();
#if UNITY_EDITOR
…
partial void PrepareForSceneWindow () {
    if (camera.cameraType == CameraType.SceneView) {
        ScriptableRenderContext.EmitWorldGeometryForSceneView(camera);
    }
}

因為這可能會給scene添加幾何體,所以必須在剔除操作之前完成。

PrepareForSceneWindow();
if (!Cull()) {
    return;
}

scene窗口中UI可見


4. 多相機(Multiple Cameras)

在場景中可能存在多個激活的相機。如果是這樣的話,我們需要確保他們可以共同工作。

4.1 兩個相機(Two Cameras)

每個相機都有一個Depth屬性,默認(rèn)主相機為?1。它們按照深度遞增的順序進行渲染。想要看到這個,可以復(fù)制Main Camera,重命名為Secondary Camera,并設(shè)置其Depth為0。給它設(shè)置另一個不同的標(biāo)簽(tag)也是一個好想法,因為MainCamera標(biāo)簽應(yīng)該只被一個相機使用。

兩個相機的采樣被劃分在一個采樣區(qū)內(nèi)

場景現(xiàn)在被渲染兩次。但最終顯示的圖像仍然是一樣的,因為渲染目標(biāo)(render target)在這之間被清除了。frame debugger中顯示了這個,但因為具有相同名稱的相鄰采樣被合并了,導(dǎo)致我們最終只得到一個Render Camera采樣區(qū)間。

如果每個相機都有自己的采樣區(qū)間,那渲染過程就更清晰可見了。為了實現(xiàn)這個,添加一個僅editor生效的PrepareBuffer()方法,使緩沖區(qū)的名稱等于相機的名稱。

partial void PrepareBuffer ();
#if UNITY_EDITOR
…
partial void PrepareBuffer () {
    buffer.name = camera.name;
}
#endif

在我們?yōu)閟cene窗口做準(zhǔn)備之前調(diào)用它。

PrepareBuffer();
PrepareForSceneWindow();

每個相機各自的采樣區(qū)


4.2 處理緩沖區(qū)名稱的變化(Dealing with Changing Buffer Names)

雖然幀調(diào)試器現(xiàn)在為每個相機單獨顯示了一個的采樣結(jié)構(gòu)列表,但當(dāng)我們進入游戲模式(play mode)時,Unity的控制臺將被填充警告(warning)消息,它警告我們BeginSampleEndSample的計數(shù)必須匹配。因為我們對采樣和緩沖區(qū)使用了不同的名稱,使它變得混亂。除此之外,每次訪問cameraname屬性時,我們都要分配內(nèi)存,所以我們不應(yīng)該在構(gòu)建項目中這樣做。

為了解決這兩個問題,我們將添加一個SampleName字符串屬性。如果我們在editor中,我們在PrepareBuffer()方法中一起設(shè)置它和緩沖區(qū)的名稱,否則它只是常量bufferName的字符串的一個別名常量。

#if UNITY_EDITOR
    …
    string SampleName { get; set; }
    …
    partial void PrepareBuffer () {
        buffer.name = SampleName = camera.name;
    }
#else
    const string SampleName = bufferName;
#endif

Setup()Submit()方法中使用SampleName采樣。

void Setup () {
    context.SetupCameraProperties(camera);
    buffer.ClearRenderTarget(true, true, Color.clear);
    buffer.BeginSample(SampleName);
    ExecuteBuffer();
}

void Submit () {
    buffer.EndSample(SampleName);
    ExecuteBuffer();
    context.Submit();
}

首先運行游戲,我們可以通過檢查分析器(Window/Analysis/Profiler)來看到區(qū)別。切換到Hierarchy模式并按GC Alloc列排序。你將看到兩個GC Alloc的項??偣卜峙?00個字節(jié)(bytes),這是由檢索相機名稱引起的。再往下看,你會看到這些名稱作為采樣項出現(xiàn):Main CameraSecondary Camera。

采樣器中分開的采樣和100B的內(nèi)存分配

接下來,選擇File-Build Settings,勾選Development BuildAutoconnect Profiler,點擊Build And Run并確保Profiler連接并錄制。在這種情況下,我們沒有看到之前100字節(jié)的GC ALLOC,只有一個簡單的的Render Camera采樣。

分析器采樣結(jié)果


其余的48個字節(jié)分配給什么?
.
這是給相機數(shù)組用的,我們無法控制這個。它的大小取決于有多少相機被渲染。


通過將相機名包裝在名為Editor Only的分析器采樣中,我們可以更清晰地表示出我們只在編輯器中分配內(nèi)存,而不是在構(gòu)建模式中。要實現(xiàn)這個,我們需要調(diào)用來自UnityEngine.Profiling命名空間的Profiler.BeginSample()Profiler.EndSample()方法。只有BeginSample()方法需要傳遞采樣器名。

…
using UnityEngine.Profiling;
…
partial class CameraRenderer {
…
#if UNITY_EDITOR
…
    partial void PrepareBuffer () {
        Profiler.BeginSample("Editor Only");
        buffer.name = SampleName = camera.name;
        Profiler.EndSample();
    }
#else
    string SampleName => bufferName;
#endif
}

清晰的Editor-Only的GC Alloc


4.3 層(Layer)

相機也可以配置為只看到特定層上的東西。這是通過調(diào)整他們的剔除遮罩(Culling Mask)來完成的。為了看到實際的效果,讓我們把所有使用了標(biāo)準(zhǔn)著色器(standard shader)的對象移動到Ignore Raycast層。

切換到`Ignore Raycast`層

將該層從Main CameraCulling Mask中剔除。

剔除`Ignore Raycast`層

并使Ignore Raycast成為Secondary Camera唯一可以看到的層。

剔除`Ignore Raycast`之外的所有層

因為Secondary Camera最后渲染,我們最終只看到無效的對象。

游戲窗口中只有`Ignore Raycast`層的對象可見


4.4 清除標(biāo)記(Clear Flags)

我們可以通過調(diào)整第二個被渲染的相機的清除標(biāo)記來合并兩個相機最終的結(jié)果。相機的清除標(biāo)記由CameraClearFlags枚舉定義,我們可以通過相機的clearFlags屬性來獲取。在Setup()方法中清除渲染目標(biāo)之前執(zhí)行此操作。

void Setup () {
    context.SetupCameraProperties(camera);
    CameraClearFlags flags = camera.clearFlags;
    buffer.ClearRenderTarget(true, true, Color.clear);
    buffer.BeginSample(SampleName);
    ExecuteBuffer();
}

CameraClearFlags枚舉定義了四個值。從1到4分別是Skybox, Color, DepthNothing。這些實際上不是單獨的標(biāo)記的值,但代表著清除量的減少。除了Nothing值,在flags的值不大于Depth的所有情況下都必須清除深度緩沖區(qū)(depth buffer)。

buffer.ClearRenderTarget(
    flags <= CameraClearFlags.Depth, true, Color.clear
);

我們只需要在flags設(shè)置為Color時清除顏色緩沖區(qū),因為在設(shè)置為Skybox的情況下,我們最終會替換所有之前的顏色數(shù)據(jù)。

buffer.ClearRenderTarget(
            flags <= CameraClearFlags.Depth,
            flags == CameraClearFlags.Color,
            Color.clear
);

如果要清除為純色,就必須使用相機的背景色。但是因為我們是在線性顏色空間中渲染的,我們需要將顏色轉(zhuǎn)換成線性空間,所以這個情況下我們需要使用camera.backgroundColor.linear。在其他情況下,顏色并不重要,所以我們用Color.clear就足夠了。

buffer.ClearRenderTarget(
        flags <= CameraClearFlags.Depth,
        flags == CameraClearFlags.Color,
        flags == CameraClearFlags.Color ? camera.backgroundColor.linear : Color.clear
);

因為Main Camera是第一個渲染的,它的Clear Flags應(yīng)該設(shè)置為SkyboxColor。當(dāng)frame debugger啟用時,我們總是從一個清除的緩沖區(qū)開始,但通常情況下這是不能保證的。

Secondary CameraClear Flags決定了兩個相機的渲染如何合并。在設(shè)置為Sky BoxColor的情況下,之前的渲染結(jié)果完全被取代。當(dāng)只有Depth被清除,Secondary Camera渲染正常,除了它不繪制一個skybox,所以以前的結(jié)果顯示為相機背景。當(dāng)什么都沒有被清除時,depth buffer就會被保留,所以無光照的對象最終會遮擋住無效的對象,就好像它們是由同一個相機繪制的一樣。然而,由前一個相機繪制的透明物體沒有深度信息,所以它們被覆蓋了,就像skybox之前做的那樣。

清除-Color

清除-Depth

清除-Nothing

通過調(diào)整相機的Viewport Rect,也可以將渲染區(qū)域減少到整個渲染目標(biāo)的一小部分,而渲染目標(biāo)的其余部分則不受影響。在這種情況下,清除操作是使用Hidden/InternalClear著色器進行的。模板緩沖區(qū)(stencil buffer)被用于限制渲染到視口區(qū)域。

縮小Secondary Camera的視口,清除Color

需要注意的是,每幀渲染多個相機意味著需要多次進行剔除、設(shè)置、排序等操作。為每個獨特的視圖使用一個相機通常是最有效的方式。


下一個章節(jié)是 繪制調(diào)用(Draw Calls)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容