原文:《A trip through the Graphics Pipeline 2011》
翻譯:往昔之劍
轉載請註明出處
歡迎回來。上個部分是關於vertex shader的,還帶有一些GPU shader通用單元的概念。重要的是,它們僅僅是向量處理器,可是它們須要訪問不在向量架構上的資源:紋理採樣器。它們是GPU管線的一部分,而且十分複雜(還頗有趣!)足以保障它們本身的協議約束,那麼就開始講解吧。
紋理狀態(Texture state)
在咱們開始實際的紋理操做以前,讓咱們來看一下驅動紋理的API狀態。在D3D11裏,這是由3個不一樣部分組成:
- 採樣器狀態。過濾模式(filter mode),尋址模式(addressing mode),各項異性過濾(max anisotropy),之類的等等。這個狀態一般控制紋理採樣如何執行。
- 底層紋理資源。這能夠歸結爲一個指向內存中的原始紋理比特位(raw texture bits)的指針。這個資源還決定了它是一個單獨的紋理仍是一個紋理數組,這個紋理使用哪一種多重採樣格式(若是使用了多重採樣的話),以及紋理比特位(texture bits)的實際佈局——例如,在資源層它尚未肯定在內存中如何被確切的解析,可是它們的內存佈局已經肯定了。
- Shader資源視圖(shader resource view,簡稱SRV)。它決定了紋理比特位如何被採樣器解析。在D3D10以上,資源視圖連接着底層資源,因此你不須要明確的指定資源。
大多數狀況,都是按照一個指定格式建立紋理資源,好比是RGBA,每一個份量(component)8 bits,而後建立一個格式匹配的SRV。不過你也能夠建立一個每一個份量8bits無類型的(typeless)紋理,而後建立多個不一樣的SRV,他們使用同一個資源但能夠按照不一樣格式讀取底層數據。例如,既能夠做爲UNORM8_SRGB(在SRGB空間裏用unsigned 8-bit映射到浮點數0..1之間),也能夠做爲UINT8(unsigned 8-bit integer)。
建立額外的SRV看起來像是多餘的步驟,可是它可讓API運行時在建立SRV的時候檢查數據類型。若是你獲得一個正確的SRV,就表示這個SRV與資源格式是兼容的,有了SRV之後就不用再作類型檢查了。換言之,這是爲了API效率。
總之,在硬件層面,歸結爲一整包關於紋理採樣操做的狀態——採樣器狀態(sampler state)和用到的紋理/格式,等——這些東西保存在某個地方(參考Part2中關於管線架構中狀態管理的多種方式的說明)。從「每次狀態改變刷新管線」到「在採樣器中徹底無狀態執行,以及隨同每一個紋理請求發送一系列東西」,中間有多種選項。你什麼都不用擔憂——這種事情,硬件架構會作成本分析,計算一些工做量,而後決定採用哪一種方法——可是這值得重複:做爲PC端程序員,不要覺得硬件遵循任何特定的模型。
不要認爲紋理切換開銷很大——它們多是徹底管線化的無狀態紋理採樣器,因此它們基本上沒開銷。可是也不要認爲徹底的沒有開銷——它們可能不是徹底管線化的,或者在管線裏的某個時刻,不一樣的紋理狀態集合有一個最大上限。除非你是在指定硬件的終端上(或者你針對每一代圖形硬件優化你的引擎),那就沒什麼好說的了。在作優化時,有一個明顯的改善是——按照材質排序,儘量的避免沒必要要的狀態改變,這樣至少能夠節省一些API操做。不要作任何基於硬件工做的特定模塊,由於在每一代硬件之間可能千差萬別。
紋理請求解析
那麼,咱們須要發送多大的關於紋理採樣請求的信息量呢?這取決於紋理類型和正在使用哪一種採樣指令。如今,假設有一個2D紋理,若是咱們想要作4x各項異性的2D紋理採樣,咱們須要發送哪些信息呢?
- 2D紋理座標——2個浮點數,在本篇裏仍是用D3D術語,稱呼它們爲u/v,而不是s/t。
- 在「x」方向上,u和v的偏導數。
- 一樣,還有在「y」方向上,u和v的偏導數。
因此,一個普通的2D紋理採樣請求須要6個浮點數——可能比你想的還要多。4個梯度值用來選擇mipmap和各項異性採樣內核的大小與形式。還可使用指定mipmap層級的紋理採樣指令(在HLSL中,是SampleLevel)。這些只是包含LOD參數的值,不須要梯度,但也不能各項異性採樣——最適合的是三線性採樣。無論怎樣,要用6個浮點數。貌似是這樣的。咱們真的須要每次紋理請求都發送它們嗎?
答案是:須要。除了在Pixel Shader裏,都是須要的(若是須要各向異性採樣的話)。在Pixel Shader中,是不須要的。有一個技巧能夠在Pixel Shader中獲得梯度指令(你能夠計算一些值,而後詢問硬件「這個值的屏幕空間梯度近似值是多少?」),這種技巧能夠被用在紋理採樣器中,經過座標獲得須要的偏導數。因此對於一個PS中的2D「sample」指令,在採樣單元中作一些數學運算,實際上只須要發送剩餘部分的2個座標。
有趣的地方:最壞的狀況下,一次紋理採樣須要多少個參數呢?在當前的D3D11管線中,最壞狀況是在Cubemap數組上執行SampleGrad操做。讓咱們來看一下統計:
- 3D紋理座標——u,v,w:3個浮點數。
- Cubemap數組索引:一個整型(這裏認爲是和一個浮點開銷同樣)。
- 屏幕上x和y的方向上(u,v,w)的梯度值:6個浮點數。
每一個採樣像素一共10個值——這樣實際上用40個字節來存儲。如今,你可能會認爲不須要所有用32位表示(對於數組索引和梯度值多是多餘的),可是 發送的數據量仍然很大。
實際上,咱們來檢查一下這裏用到的帶寬類型。咱們假設咱們的紋理大部分都是2D的(還伴有cubemap),大多數紋理採樣都來自Pixel Shader中,Vertex Shader中幾乎沒有紋理採樣,而且常規採樣類型的請求是最爲頻繁的,實際上是SampleLevel(這些都是在 遊戲實際渲染中很典型的)。這意味着每一個像素髮送32位浮點值的 平均個數介於2*(u+v)和3*(u+v+w/u+v+lod)之間,好比2.5或10個字節。
假設一箇中等分辨率大小——好比1280x720,大於92萬像素。Pixel Shader平均有多少次紋理採樣?至少是3。假設有適量的overdraw,那麼在整個3D渲染階段,咱們要處理的屏幕像素大約會是兩倍。咱們處理完以後,將幾張 全屏紋理傳遞給後期處理(post-processing)。這可能會每像素增長6次採樣,考慮到一些後期處理將在下降的分辨率下執行。加起來是0.92*(3*2+6)=大約是每幀1100萬次紋理採樣,30fps大約就是 每秒3.3億次。每一個請求10個字節,就是3.3GB/s的紋理請求帶寬。這只是下限,由於還有一些額外的開銷(接下來講)。當今的遊戲在一塊較好的DX11顯卡上以高分辨率運行,要比我列出的有更多複雜的shader,大量的overdraw,甚至是一些延遲着色/光照,更高的幀率,以及更復雜的後期處理方式——作一個快速粗略的計算,在四分之一分辨率下采用雙邊升採樣(bilateral upsampling)的高品質SSAO須要多少紋理請求帶寬呢……
要說的是,整個紋理帶寬是你操做不了的。紋理採樣器不是shader核心的一分部,他們是芯片上的獨立單元,不只僅經過自身來處理每秒幾千兆的字節。這是架構上的問題——這是件好事,咱們不用在Cubemap數組上使用SampleGrad。
可是誰來請求紋理採樣呢?
答案固然是:沒有人。咱們的紋理請求都來自shader單元,咱們知道每次在哪裏處理16~64個像素/頂點/控制點/……。因此咱們的shader不會發送個別的紋理採樣,它們是一次派發一批量的。此次,我使用16舉例——這樣簡單一點,由於我上次用的32是非正方形的, 當談論2D紋理請求時,這看起來有點奇怪。那麼,一次16個紋理請求——構成了紋理請求的負載,加上開頭的一些採樣器如何執行的指令字段,再加上一些採樣器用到哪一個紋理和採樣狀態的字段,併發送到某個紋理採樣器。
這將耗費一段時間。
耗時是很嚴重的。紋理採樣器有一個很長的管線(咱們很快就會知道爲何);一次紋理採樣操做很是耗時,儘管shader單元只是閒置的。咱們又要談到:吞吐量。那麼一次紋理採樣究竟發生了什麼,一個shader單元只是安靜的切換到另外一個線程/批次,而且執行另外一項工做,而後獲得結果後再切換回來。只要shader單元 有充分獨立的工做就會執行的很好。
這裏首先有大量的計算要執行:(這裏,我假設是一次簡單的雙線性採樣;三線性和各項異性要作更多的工做,見下文)。
- 若是這是一個Sample或者SampleBias類型的請求,先要計算紋理座標的梯度值。
- 若是沒給出明確的mip level,要根據梯度值計算採樣用到的mip level,若是指定了LOD bias,還要再加上它。
- 對於每一個生成的採樣位置,應用尋址模式(wrap/clamp/mirror等)來獲得正確的紋理採樣位置(在規範化的[0, 1]座標之間)。
- 若是是個cubemap,咱們還要肯定採樣哪一個cube面(根據絕對值和u/v/w座標的符號),而且相除,把座標投影到單位cube上,這樣它們在[-1, 1]之間。咱們還須要丟棄3個座標其中的一個(根據所在的cube面)和另外兩個座標的縮放/偏差,因此對於咱們常規的紋理採樣,它們一樣在[0, 1]的規範化座標空間裏。
- 下一步,拿到[0, 1]的規範化座標而且轉換到定點像素座標來採樣——咱們須要一些雙線性插值的分數位數。
- 最終,從整數x/y/z和紋理數組索引中,咱們能夠計算出讀取像素的地址。
若是你認爲這聽起來總結的很差,我再來提醒你一下,這是個簡化圖。上面的總結都沒涵蓋紋理邊界和cubemap邊角的採樣問題。相信我,如今可能聽起來很差,可是若是你實際的對全部事物編碼,你確定會嚇壞的。好消息是咱們有專門的硬件負責作這件事:)不管如何,咱們如今有一個內存地址來獲取數據了。而且內存地址的附近還有一兩個緩存。
紋理緩存
現在幾乎都用兩級紋理緩存。二級緩存徹底是個普通的緩存,用來緩存包含紋理數據的內存。一級緩存不是很標準,由於它用到其它 技術。每一個採樣器大概4~8kb大小,可能比你預想的還要小。咱們先來說下大小尺寸,由於它每每讓大多數人感到驚訝。
事情是這樣的:大多數紋理採樣在Pixel Shader中要開啓mip-mapping,由採樣的mip層級來決定用到的屏幕像素:像素比例約爲1:1——這就是關鍵點。可是這意味着,除非你每次都碰巧命中紋理上的同一個位置,不然每次紋理採樣操做平均都會miss1個像素——雙線性採樣的 實際測量值大約是每次miss1.25個像素(若是你跟蹤單獨的像素)。這個值基本不會隨着改變紋理緩衝大小而變更,除非紋理緩存能夠足夠容納下整張紋理(一般是幾百kb幾兆,對於L1緩存是不切實際的)。
基於這點,紋理緩存有很大優勢(因爲它的存在,讓每次雙線性採樣大約4次的內存訪問下降到了1.25次)。可是不一樣於CPU或者shader內核的共享內存,紋理緩存的進展很緩慢,只是從4k緩存增長到了16k;大紋理數據都是串行經過緩存流化的。
第二點:因爲平均每次採樣有1.25次非命中,紋理採樣器管線須要足夠長來保障每次採樣讀取內存不會有停滯(stalling)。換句話說: 對於一次內存讀取,紋理採樣器管線足夠長了,即便要花費400~800時鐘週期也 不會有間斷。這個管線很是長——在字面意義上它確實是個管線,將沒處理的數據從一個管線寄存器傳給下一個,通過幾百個時鐘週期,直到內存讀取完成。
所以,L1緩存很小,管線很長。那關於「其它技術」是什麼呢?好吧,這就是壓縮紋理格式。在PC上能夠見到——S3TC又稱爲BC1~3格式,還有D3D10中引進的BC4和5格式,都是DXT格式的變種,最後還有D3D11引進的BC6H和7格式——它們都是基於塊的方法,單獨編碼4x4像素塊。若是在紋理採樣時解碼,每一個時鐘週期須要一塊兒解碼4個像素塊並從每一個塊中取得一個像素。這太操蛋了。因此在加載到L1緩衝的時候,4x4塊就被解碼了:好比BC3(DXT5)格式,你從L2紋理緩存中取得一個128位塊,而後解碼到16個像素的紋理緩存。如今每次採樣只須要解碼1.25/(4*4)=大約0.08塊,取代了原來每次採樣不得不解碼4個塊,至少若是紋理訪問模式足夠連貫了,實際上能夠命中 你須要的位於 旁邊另外15個解碼的像素了:)即便你最後用到超出了L1緩衝的部分,這仍然是一個很大的改善。這種技術也僅限於DXT的塊;經過D3D11緩衝 填充路徑,你能夠處理大於50種紋理格式的需求,這大約能命中一般的實際像素讀取路徑的三分之一。舉個例子,像UNORM sRGB格式的紋理能夠在紋理緩存中被轉換成每一個通道16位整型的sRGB像素(或者每一個通道16位浮點數,再或者32位浮點數,按照你的意願)。而後在合適的線性空間中執行濾波操做。注意,這最終會L1緩存中的像素覆蓋區域,因此你可能想要增長L1緩存紋理的大小;不是由於你須要緩存更多的像素,而是由於緩存的像素更富裕。實際上,這是一種權衡。
濾波(Filtering)
在這一點上,實際的雙線性過濾操做過程是很簡單的。從紋理緩存中抓取4個採樣點,使用小數位混合它們。多一點會用上乘法累積單元。(實際上不少——一次處理4個通道的時候這樣作)。
三線性濾波呢?就是兩次雙線性濾波採樣和另外一個線性插值 。只是在管線中再添加一些乘法計算。
各向異性採樣呢?須要在管線中提早作一些額外的工做,最初要計算採樣的mip-level。咱們要作的是查看梯度值來決定不只是區域還有像素空間中的屏幕像素形狀;若是它們寬高大體同樣,就只執行一次常規的雙/三線性採樣,可是若是它們在一個方向上不一致,要在這條線上採樣幾回並把採樣結果混合起來。這樣生成了一些採樣位置,因此最終要遍歷所有的雙/三線性的管線若干次,採樣位置和相對權重的計算對於硬件供應商來講是嚴格保密的;他們研究這個問題已經不少年了,如今的硬件開銷都性能很好。我不想猜想他們是怎麼實現的。說實話,做爲圖形程序員,只要它工做正常而且沒性能問題,你就不須要去關心各向異性濾波的底層實現算法。
無論怎樣,除了設置和所需採樣點的排序邏輯以外,這不會給管線 增長大量的計算。在實際濾波階段,咱們有足夠的乘法累積單元來計算各向異性濾波的權重之和, 不用額外的硬件。
紋理返回
如今,咱們快到紋理採樣器管線的最後了。全部這些的結果是什麼?每次紋理採樣請求有4個值(r,g,b,a)。不一樣於紋理請求,請求尺寸大小有顯著的變化,這裏目前爲止最多見的只是shader消耗4個值。提醒一下,發送回4個浮點數的帶寬也是不能忽視的,某些狀況下能夠剔除一些位數。若是你的shader是採樣一張32位浮點通道的紋理,你最好返回32位浮點,可是若是是讀取一張8位UNORM sRGB紋理,返回32位就多餘了,你能夠用一個更小的返回格式來節省帶寬。
就是說——shader單元有本身的紋理採樣返回結果,而且能夠在你提交的批次上繼續工做——這是本部分的總結。咱們下篇再見,在談到實際開始光柵化圖元以前的須要作的工做時。更新:這是一幅紋理採樣管線圖,有個錯誤在圖中修復了。
補充說明
此次就不用免責聲明瞭。帶寬中提到的例子真的是由於我找不到實際數據:),但除此以外,我這裏描述的應該很接近於實際的GPU了,即便我告別了濾波的部分,等(主要是由於實現細節太噁心了)。
至於紋理L1緩存包含的壓縮紋理數據,據我說知針對於當今的硬件是很準確的。一些老硬件在L1紋理緩存中甚至還保留了一些其它壓縮格式,可是因爲「每次採樣一大塊緩衝有1.25次miss」的規律,這些就不重要了,可能不值得複雜化。我認爲這些如今都消失了。
嵌入式/功耗優化的圖形芯片是頗有趣的,例如PowerVR;在本系列中,我不會深刻這類芯片太多,由於個人關注點是PC端高性能部分,可是若是你感興趣我在評論中有一些以前部分的說明。
PVR芯片有它本身的紋理壓縮格式,不是基於塊的,而且緊密的集成在它的濾波硬件中,因此我認爲在L1紋理緩存中保留着它們的壓縮紋理(實際上,我不知道是否有二級緩存!)。這是一個頗有趣的方法,並且可能在每一個區域和能源耗費上頗有效。可是我認爲「解壓到L1緩存」的方法會有更高的吞吐量,不說太多了,這裏都是講的高端PC的GPU:)