THREE.js渲染順序

luckness發表於2022-01-05

本文將會講述THREE.js渲染順序,這裡的渲染順序指的是處於前後位置的物體,是如何渲染出來物體之間的遮擋關係的。

主要的講述內容包括:

  1. 不透明物體的預設渲染順序是怎樣的;
  2. 透明物體的預設渲染順序是怎樣的;
  3. 不透明物體和透明物體一起渲染的時候,預設渲染順序是怎樣的;
  4. 如何改變物體的預設渲染順序。

不透明物體的預設渲染順序是怎樣的

首先,我們通過一個簡單的例子介紹下我們要實現的效果:

一個基本場景,場景中放置兩個4X4正方形,正方形與XOY平面平行,紅色正方形放在(0, 0, 0)的位置,綠色正方形放在(2, 0, -2)的位置。

相機使用THREE.PerspectiveCamera,這種相機會有近大遠小的效果,相機放置在(0, 0, 6)的位置,此時,相機看向Z軸的負方向。

相機和兩個平面的空間位置如下圖1、圖2所示:
image.png
圖1

image.png
圖2

通過相機和兩個平面的空間位置關係,我們知道

  1. 紅色平面在綠色平面的前面顯示;
  2. 紅色平面會遮擋綠色平面的一部分;

最後呈現效果如下圖3所示:
image.png
圖3

此時,你可以先自己思考一下,如果讓你自己實現,你會如何實現?

我當時自己想了一下,首先我們是知道這兩個物體和相機之間的距離的,我們可以按照物體距離相機的遠近給物體排個序,然後按照由遠及近的順序渲染物體。也就是先渲染綠色的平面,然後渲染紅色的平面。上面這個簡單的例子沒有問題,那對其他複雜的場景是否適用呢?

比如,這兩個平面距離相機的位置是一樣的?如下圖4所示:
image.png
圖4

按照我們剛才的設想,對於上述情景,我們渲染出來的是要麼綠色完全在上面,要麼紅色完全在上面,如下圖5或圖6所示:
image.png
圖5
image.png
圖6

但是實際的渲染結果應該如下圖7所示:
image.png
圖7

所以上述按照由遠及近的順序渲染的方案只能滿足部分使用場景。那麼,THREE.js是如何實現的呢?

THREE.js主要是使用WebGL構建3D場景的開源庫,是對WebGL提供的能力的一個易用性封裝。所以在探討THREE.js中物體的渲染順序之前就得先看下WebGL是如何渲染不同位置的物體的。而WebGL是基於OpenGL的,所以這個問題就變成了OpenGL是如何渲染不同位置的物體的。

OpenGL是使用深度測試(depth testing)來保證物體渲染出來正確的遮擋關係的。深度測試使用了深度緩衝區(depth buffer)。和顏色緩衝區(color buffer)類似,只不過顏色緩衝區中儲存的是每個畫素點的色值,而深度緩衝區儲存的是顏色緩衝區當前畫素點的色值所在的深度。深度緩衝區和顏色緩衝區具有相同的寬度和高度。

那麼,在渲染過程中,對於每一個畫素點,我們儲存的資料包括:

  1. 當前畫素的色值(通過顏色緩衝區獲取);
  2. 當前畫素對應的物體片元在空間中的深度(通過深度緩衝區獲取)。

下面,我們通過上述圖4的例子說明一下,OpenGL是如何通過深度測試(depth testing)實現物體正確的遮擋關係的。

假設物體先繪製綠色的平面,物體在繪製的時候是逐畫素繪製的,此時我們已知的資訊包括畫素的色值和深度資訊。對於綠色平面投影到的每一個畫素,我們在顏色緩衝區中該畫素的位置寫入色值,在深度緩衝區中該畫素的位置寫入深度資訊。

然後,我們開始繪製紅色的物體。當繪製紅色物體的每一個畫素時,我們知道了該畫素的色值和深度資訊D2。然後,我們根據該畫素的座標,獲取深度緩衝區中已繪製畫素對應的深度值D1,然後比較D1和D2的值。有以下三種情況:

  1. 如果D2小於D1,那就是該物體的當前畫素在前面,也就是該畫素應該取該物體的當前畫素的色值。此時,更新顏色緩衝區當前畫素的色值為紅色,更新深度緩衝區當前畫素的深度為D2;
  2. 如果D2大於D1,那就是該物體的當前畫素在後面,也就是該畫素不會顯示出來,所以當前畫素的顏色緩衝區和深度緩衝區的值都不用改變;
  3. 如果D2等於D1,行為和D2小於D1一致。

總結來說,就是我們有一個判斷該畫素點是否渲染的函式。該函式的輸入是

  1. 當前等待渲染的畫素點的深度值;
  2. 深度緩衝區中當前畫素點的深度值。

輸出是一個布林值表明當前畫素是否使用新的色值渲染。

THREE.js中該函式的預設值是LessEqualDepth,也就是上述D2和D1比較的三種情況。該函式的所有取值可以參考THREE.js官網Depth Mode

所以,還是上面那個例子:

  1. 對於紅色物體左邊的每一個畫素,該深度值D2小於深度緩衝區中的深度值D1,所以顏色緩衝區更新為紅色。
  2. 對於紅色物體右邊的每一個畫素,該深度值D2大於深度緩衝區中的深度值D1,所以保持原來的顏色。

最終的結果就是左半邊是紅色,右半邊是綠色。

我們上面分析了先繪製綠色平面,再繪製紅色平面的情況。你可以自己嘗試分析先繪製紅色平面,再渲染綠色平面的情況。

最後的結果就是渲染結果的遮擋關係基本和繪製的先後順序無關。

透明物體的預設渲染順序是怎樣的

前面講述了不透明物體的渲染順序,那麼,如果場景中的物體都是透明物體的時候,又是如何渲染的呢?

還是拿前面的例子舉例,此時我們把兩個平面都設定成半透明的。如下圖8所示:
image.png
圖8

如果還是用前面那個邏輯,每個畫素點的顏色要麼不變,要麼使用新物體的顏色,加上深度測試的邏輯之後,渲染出來的效果如下圖9所示:
image.png
圖9

在現實生活中,透過透明的物體我們應該是可以看到該透明物體後面的物體的。顯然,圖9並沒有實現這樣的效果。那麼,問題出在什麼地方呢?

我們前面在深度測試的時候,在往顏色緩衝區中寫入色值的時候,要麼寫入當前物體的色值,要麼丟棄當前物體的色值。而對於透明物體來說,最終顯示的色值並不是單個物體的顏色,而是多個可見物體顏色的一個混合(blend)。

那麼,在前面步驟中,當我們判斷當前物體是在前面時,可以從簡單粗暴的直接使用該色值變為根據當前物體的色值和在顏色緩衝區中的色值按照透明度進行一個混合,然後使用混合後的色值更新顏色緩衝區。

THREE.js提供了多種blend方法,預設是NormalBlending。NormalBlending的計算公式如下:
color(RGB) = (sourceColor * sourceAlpha) + (destinationColor * (1 - sourceAlpha))
color(A) = (sourceAlpha * 1) + (destinationAlpha * (1 - sourceAlpha))

新增上混合邏輯,最後實現出來的效果如下圖10所示,這個效果也是符合我們心理預期的一個效果:
image.png
圖10

在渲染不透明物體的時候,我們發現最終的實現效果和物體繪製的前後順序沒有關係。那麼,對於透明物體呢?我們實驗一下:

先渲染紅色平面,再渲染綠色平面

渲染效果如下圖11所示:
image.png
圖11

先渲染綠色平面,再渲染紅色平面

渲染效果如下圖12所示:
image.png
圖12

可以看到,對於透明物體的渲染來說,繪製的先後順序會影響渲染結果的遮擋關係。那麼這是什麼原因導致的呢?

我們分析下先渲染紅色平面,然後渲染綠色平面的情況。首先,繪製完紅色平面之後,顏色緩衝區和深度緩衝區中儲存的是紅色平面相關的資料。此時,對於被紅色平面遮擋的每一個綠色畫素來說,先進行深度測試,深度測試失敗了,所以該畫素直接丟棄了。

所以這裡的問題就是,當深度測試成功的時候,我們可以選擇是否混合以及混合的函式;但是當深度測試失敗的時候,是直接丟棄該畫素,而不是也給你提供一個函式,讓你自定義這個畫素的色值。

綜上,透明物體的最終渲染結果和物體的繪製順序相關。當透明物體按照由遠及近的順序繪製時,結果會在更大程度上符合我們的預期;當透明物體按照由近及遠的順序繪製時,結果基本上不會符合我們的預期,除非你是有意為之。

上面之所以說是在更大程度上而不是一定的原因是存在一些特殊的情況。從上述結論中,我們也可以知道渲染結果和繪製順序相關。我們可以在繪製物體之前先給物體排個序。但是需要注意的是,我們排序使用的是一個表示物體整體位置的座標資訊,而不是根據物體的每個畫素進行排序。所以對於兩個交叉的物體,無論繪製順序是什麼樣的,最後的渲染結果都是不正確的。如下圖13、14所示:
image.png
圖13
image.png
圖14

就上述這種情況,目前我還沒有找到解決方案。

不透明物體和透明物體一起渲染的時候,預設渲染順序是怎樣的

如果我們的場景中既有不透明物體又有透明物體,那麼,在前面的基礎上試想一下,我們應該如何實現呢?

首先,

  1. 對於不透明物體來說,不要求繪製順序;
  2. 對於透明物體來說,需要按照由遠及近的順序繪製;

那總結起來,是不是可以把所有的物體按照由遠及近的順序進行排序,然後按照這個順序進行繪製呢?

我自己想了下覺得沒有問題,但是發現THREE.js並不是按照這個邏輯實現的。我們先說下THREE.js的預設渲染順序:

  1. 首先,把場景中的物體根據是否透明劃分為兩個陣列;
  2. 對於不透明物體所在的陣列,按照由近及遠的順序排序;
  3. 對於透明物體所在的陣列,按照由遠及近的順序排序;
  4. 繪製不透明物體所在的陣列;
  5. 繪製透明物體所在的陣列。

我想了下,THREE.js之所以這樣實現的原因應該是從效能方面考慮。

首先,對於不透明物體來說,雖然繪製順序對渲染結果沒有影響,但是對渲染效能還是有影響的。舉例來說,比如兩個平行的平面AB,平面A比平面B的距離近,此時:

  1. 先繪製A,再繪製B:

    1. 平面A的所有畫素執行深度測試,測試成功,重寫顏色和深度緩衝區;
    2. 平面B的所有畫素執行深度測試,對於不被平面A遮擋的部分,重寫顏色和深度緩衝區;對於被遮擋的部分,深度測試失敗,直接返回;
  2. 先繪製B,再繪製A:

    1. 平面B的所有畫素執行深度測試,測試成功,重寫顏色和深度緩衝區;
    2. 平面A的所有畫素執行深度測試,測試成功,重寫顏色和深度緩衝區。

通過上述對比可以發現,當對不透明物體按照由近及遠的順序繪製的時候,是可以省掉後面遮擋部分重寫顏色和深度緩衝區的操作的,所以在一定程度上提高了效能。

其次,當我們按照由近及遠的順序繪製完不透明物體,開始繪製透明物體的時候,在不透明物體後面的透明物體深度測試失敗,所以不會執行下面的顏色和深度緩衝區的更新操作,所以也能在一定程度上提高渲染透明物體的效能。

所以,如果不做區分,一起繪製不透明物體和透明物體的時候,那麼所有的物體都得按照由遠及近的順序繪製。那麼,大部分情況下的深度測試都會成功,也就是會有更多的顏色和深度緩衝區的更新操作,在一定程度上影響了效能。

如何改變物體的預設渲染效果

前面我們提到的大部分都是預設繪製順序的效果,但是如果你想改變預設渲染效果,那有沒有什麼方法呢?

答案是有的。

控制深度測試

前面我們有說到深度測試,深度測試的三個步驟都是可以控制的:

  1. 是否進行深度測試;
  2. 深度測試函式的行為;
  3. 是否更新深度緩衝區。

這三個步驟分別是通過Material的下面三個屬性控制的:

  1. depthTest:是否進行深度測試;
  2. depthFunc:深度測試函式的行為;
  3. depthWrite:是否更新深度緩衝區。

此外,當你需要開啟深度測試的時候,需要在初始化WebGLRenderer的時候開啟depth引數,這個引數會建立一個深度緩衝區。這個引數的預設值是true,也就是一般情況下你不用關注這個屬性。當然,如果你的需求明確不需要深度測試,並且效能要求比較高的話,你可以手動關閉這個值,減少一定的儲存成本。

控制繪製順序

前面我們有提到,THREE.js繪製不透明物體和透明物體分別是按照由近及遠和由遠及近的順序繪製的,那麼這個排序是THREE.js給我們實現的嗎?還是需要我們自己控制繪製順序?

THREE.js預設是開啟排序的,這個是通過WebGLRenderer的sortObjects屬性實現的。如果不開啟自動排序,繪製順序就是物體的新增順序(注意,此時透明物體和非透明物體仍然是分開渲染的)。

不透明物體和透明物體的預設排序順序可以參考原始碼的painterSortStable和reversePainterSortStable方法。

那麼,我們如何幹預上述排序過程呢?主要有如下兩種方式:

  1. 上述兩個方法,我們可以注意到其中有renderOrder屬性,這個屬性就是我們需要的,具體說明可以參見renderOrder文件
  2. 通過setOpaqueSortsetTransparentSort完全自定義排序邏輯。

自定義混合(blend)函式

前面我們講透明物體渲染的時候有提到透明物體的預設混合函式是NormalBlending。這個混合函式的行為也是可選的,具體可支援的行為可以參考Material.blending

總結

本文主要講述了THREE.js中的不透明物體和透明物體的渲染順序,主要涉及THREE.js的以下內容:

  1. Material

    1. depthWrite(default is true)
    2. depthTest(default is true)
    3. depthFunc(default is LessEqualDepth)
    4. blending及blending相關的一系列屬性
  2. Object3D

    1. renderOrder(default is 0)
  3. WebGLRenderer

    1. depth
    2. sortObjects(default is true)
    3. setOpaqueSort
    4. setTransparentSort

上述觀點是基於目前對THREE.js的研究結果,可能會有認知錯誤。如有,歡迎留言評論。