第八章 紋理

我們的Demo變得開始有趣,但真實世界的對象的細節通常比每個頂點顏色可以捕捉到的更多。紋理映射是一種將圖像數據映射到三角網格的技術,從而增加場景的細節和真實感。如,我們可以通過在每一邊映射一個箱子紋理來構建一個立方體,並把它變成一個箱子(圖8.1)。

學習目標:
1.瞭解如何指定映射到三角網格的紋理。
2.瞭解如何創建和啓用紋理。
3.瞭解如何過濾紋理以創建更平滑的圖像。
4.發現如何用地址模式多次平鋪紋理。
5.瞭解如何將多個紋理結合起來以創建新的紋理和特殊效果。
6.學習如何通過紋理動畫創建一些基本的效果。

8-1
圖8.1 箱子演示創建一個帶有箱子紋理的立方體。

8.1紋理和資源重構

其實自從第4章以來,我們已經在使用紋理了; 特別是深度緩衝區和後臺緩衝區是由ID3D11Texture2D接口表示的2D紋理對象。爲了便於參考,在第一部分中,我們回顧了第4章中已經介紹的很多材質。

2D紋理是數據元素的矩陣。二維紋理的一個用途是存儲二維圖像數據,紋理中的每個元素存儲像素的顏色。但是,這不是唯一的用法; 例如,在稱爲法線貼圖的高級技術中,紋理中的每個元素都存儲一個3D矢量,而不是一個顏色。因此,雖然通常認爲紋理是存儲圖像數據,但它們實際上用途更廣。一維紋理(ID3D11Texture1D)就像一個數據元素的一維數組,3D紋理(ID3D11Texture3D)就像一個3D數組元素。1D,2D和3D紋理接口都從ID3D11Resource繼承。

正如本章後面將討論的那樣,紋理不僅僅是數據的數組, 他們可以有mipmap級別和GPU可以對它們進行特殊操作,如應用濾波和多重採樣。但是,紋理不是任意的數據塊; 它們只能存儲某些類型的數據格式,這些數據格式由DXGI_FORMAT枚舉類型描述。一些示例格式是:
1 . DXGI_FORMAT_R32G32B32_FLOAT:每個元素有三個32位浮點組件。
2 . DXGI_FORMAT_R16G16B16A16_UNORM:每個元素都有四個映射到[0,1]範圍的16位組件。
3 . DXGI_FORMAT_R32G32_UINT:每個元素有兩個32位無符號整數分量。
4 . DXGI_FORMAT_R8G8B8A8_UNORM:每個元素有四個8位無符號分量映射到[0,1]範圍。
5 . DXGI_FORMAT_R8G8B8A8_SNORM:每個元素有四個8位有符號分量映射到[-1,1]範圍。
6 . DXGI_FORMAT_R8G8B8A8_SINT:每個元素有四個8位有符號整數分量映射到[-128,127]範圍。
7 . DXGI_FORMAT_R8G8B8A8_UINT:每個元素有四個映射到[0,255]範圍的8位無符號整數分量。

請注意,R,G,B,A字母分別代表紅色,綠色,藍色和alpha。 但是,之前講過,紋理不需要存儲顏色信息; 例如格式

DXGI_FORMAT_R32G32B32_FLOAT

具有三個浮點組件,因此可以存儲具有浮點座標的3D矢量(不一定是顏色矢量)。 也有無類型的格式,我們只保留內存數據,然後指定在紋理綁定到渲染管線時,如何在以後重新解釋數據(類似於強制轉換); 例如,以下無類型格式保留具有四個8位分量的元素,但不指定數據類型(例如,整數,浮點,無符號整數):
DXGI_FORMAT_R8G8B8A8_TYPELESS

紋理可以被綁定到渲染管線的不同階段; 一個常見的例子是使用紋理作爲渲染目標(即,Direct3D繪製到紋理中)和作爲着色器資源(即紋理將在着色器中被採樣)。 爲這兩個目的創建的紋理資源將被賦予綁定標誌:

D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE

表明紋理將被綁定到兩個流水線階段。其實資源並不是直接綁定到流水線階段,而是將其關聯的資源視圖綁定到不同的流水線階段。對於每種我們要使用紋理的方式,Direct3D都要求我們在初始化時創建一個紋理的資源視圖。這主要是爲了提高效率,正如SDK文檔指出的那樣:“這允許在創建視圖時在運行時和驅動程序中進行驗證和映射,在綁定時最小化類型檢查。”因此,使用紋理作爲呈現的示例目標和着色器資源,我們需要創建兩個視圖:一個渲染目標視圖(ID3D11RenderTargetView)和一個着色器資源視圖(ID3D11ShaderResourceView)。資源視圖基本上做了兩件事:它們告訴Direct3D資源將被如何使用(即,你將綁定到哪個管道階段),如果資源格式在創建時被指定爲無類型,那麼現在我們必須聲明在創建視圖時鍵入。因此,對於無類型格式,紋理的元素可能在一個流水線階段被視爲浮點值,在另一個流水線階段被視爲整數;這基本上等於重新解釋數據的轉換。

NOTE:8月份的SDK文檔說:“創建完全類型的資源會將資源限制爲其創建的格式。 這使得運行時可以優化訪問[…]。“因此,如果你真的需要,你應該只創建一個無類型的資源。 否則,創建一個完全類型的資源。

爲了創建資源的特定視圖,必須使用該特定的綁定標誌創建資源。 例如,如果資源未使用D3D11_BIND_SHADER_RESOURCE綁定標誌創建(表示紋理將作爲深度/模板緩衝區綁定到管道),則我們無法爲該資源創建一個ID3D11ShaderResourceView。 如果你嘗試,你應該得到像下面這樣的Direct3D調試錯誤:

D3D11: ERROR: ID3D11Device::CreateShaderResourceView: A ShaderResourceView cannot be created of a Resource that did not specify the D3D11_BIND_SHADER_RESOURCE BindFlag.

在本章中,我們只會將紋理綁定爲着色器資源,以便像素着色器可以對紋理進行採樣並使用它們對像素着色。

8.2紋理座標

Direct3D使用一個紋理座標系統,該系統由一個水平方向的u軸和一個垂直方向的v軸組成。座標(u,v)中0≤u,v≤1,標識紋理上的紋理元素。注意,v軸“向下”爲正(見圖8.2)。另外,請注意標準化的座標區間[0,1],創造了一個與尺寸無關的範圍,如,無論實際紋理尺寸是256×256,512×1024還是2048×2048像素,(0.5,0.5)總是指定中間紋理。同樣,(0.25,0.75)表示紋理在水平方向上總寬度的四分之一處,在垂直方向上總高度的四分之三處。目前,紋理座標總在[0,1]範圍內,但是稍後將解釋當超出此範圍時會發生什麼。

8-2
圖8.2 紋理座標系統,有時稱爲紋理空間。

對於每個3D三角形,我們要在要映射到3D三角形的紋理上定義一個相應的三角形(參見圖8.3)。假設p0p1p2 是具有相應紋理座標q0q1q2 的3D三角形的頂點。對於3D三角形上的任意一點(x,y,z),其紋理座標(u,v)通過在三維三角形上用相同的s,t參數對頂點紋理座標進行線性插值來求解;那就是,如果
8-3
圖8.3 左邊是三維空間中的一個三角形,右邊我們定義了要映射到三角形上的紋理上的二維三角形。

(x,y,z)=p=p0+s(p1p0)+t(p2p0)

如果s0,t0,s+t1 則有:
(u,v)=q=q0+s(q1q0)+t(q2q0)

這樣,三角形上的每一個點都有相應的紋理座標。

因此,我們再次修改頂點結構並添加一對紋理座標來標識紋理上的一個點。現在每個3D頂點都有相應的2D紋理頂點。因此,由三個頂點定義的每個3D三角形也定義了紋理空間中的二維三角形(即,我們已經爲每個三角形關聯了2D紋理三角形)。

// Basic 32-byte vertex structure.
struct Basic32
{
    XMFLOAT3 Pos;
    XMFLOAT3 Normal;
    XMFLOAT2 Tex;
};
const D3D11_INPUT_ELEMENT_DESC InputLayoutDesc::Basic32[3] =
{
    {"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"NORMAL", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 12,
D3D11_INPUT_PER_VERTEX_DATA, 0},
    {"TEXCOORD", 0, DXGI_FORMAT_R32G32_FLOAT, 0, 24,
D3D11_INPUT_PER_VERTEX_DATA, 0}
};

NOTE:您可以在2D紋理三角形與3D三角形大不相同的情況下創建“畸形”紋理映射。 因此,當2D紋理被映射到3D三角形時,會出現大量的拉伸和變形,使得結果看起來不好。 例如,將銳角三角形映射到直角三角形需要拉伸。 一般來說,紋理失真應該被最小化,除非紋理藝術家需要失真外觀。

注意到在圖8.3中,我們將整個紋理圖像映射到立方體的每個面上。這絕不是必需的。 我們可以只將紋理的一個子集映射到幾何圖形上。事實上,我們可以在一個大的紋理(這被稱爲紋理貼圖)上放幾個不相關的圖像,並將其用於幾個不同的對象(圖8.4)。紋理座標決定紋理的哪一部分被映射到三角形上。

8-4
圖8.4 在一個大紋理上存儲四個子紋理的紋理圖集。 設置每個頂點的紋理座標,以便將紋理的所需部分映射到幾何體。

8.3創建和啓用一個紋理

紋理數據通常從存儲在磁盤上的圖像文件中讀取並加載到ID3D11Texture2D對象中(請參閱D3DX11CreateTextureFromFile)。但是,紋理資源不會直接綁定到渲染管道。 相反,您將創建着色器資源視圖(ID3D11ShaderResourceView)到紋理,然後將視圖綁定到管道。所以需要採取兩個步驟:
1.調用D3DX11CreateTextureFromFile從存儲在磁盤上的映像文件創建ID3D11Texture2D對象。
2.調用ID3D11Device :: CreateShaderResourceView爲紋理創建相應的着色器資源視圖。

以下D3DX功能可以同時完成這兩個步驟:

HRESULT D3DX11CreateShaderResourceViewFromFile(
    ID3D11Device *pDevice,
    LPCTSTR pSrcFile,
    D3DX11_IMAGE_LOAD_INFO *pLoadInfo,
    ID3DX11ThreadPump *pPump,
    ID3D11ShaderResourceView **ppShaderResourceView,
    HRESULT *pHResult
);

1 . pDevice:指向D3D設備創建紋理的指針。
2 . pSrcFile:要加載的圖像的文件名。
3 . pLoadInfo:可選的圖像信息; 指定null以使用源圖像中的信息。 例如,如果我們在這裏指定null,那麼源圖像尺寸將被用作紋理尺寸; 也會生成完整的mipmap鏈(第8.4.2節)。這通常是一個很好的默認選擇。
4 . pPump:用於產生一個新的線程來加載資源。要在主線程中加載資源,請指定null。在本書中,我們將一直指定null。
5 . ppShaderResourceView:返回指向從文件加載的紋理創建的着色器資源視圖的指針。
6 . pHResult:如果爲pPump指定了null,則指定null。

此功能可以加載以下任何圖像格式:BMP,JPG,PNG,DDS,TIFF,GIF和WMP(請參閱D3DX11_IMAGE_FILE_FORMAT)。

NOTE:有時我們會將紋理及其相應的着色器資源視圖互換爲可交換的。例如,我們可能會說我們正在將紋理綁定到管線上,即使我們真的在約束它的視圖。

例如,要從名爲WoodCreate01.dds的圖像創建紋理,我們將寫入以下內容:

ID3D11ShaderResourceView* mDiffuseMapSRV;
HR(D3DX11CreateShaderResourceViewFromFile(md3dDevice,
    L"WoodCrate01.dds", 0, 0, &mDiffuseMapSRV, 0 ));

加載紋理後,需要將其設置爲一個效果變量,以便它可以在像素着色器中使用。 .fx文件中的2D紋理對象由Texture2D類型表示; 例如,我們在效果文件中聲明一個紋理變量,如下所示:

// Nonnumeric values cannot be added to a cbuffer.
Texture2D gDiffuseMap;

如註釋,紋理對象放置在常量緩衝區之外。我們可以從我們的C ++應用程序代碼中獲得一個效果的Texture2D對象(這是一個着色器資源變量)的指針,如下所示:

ID3DX11EffectShaderResourceVariable* DiffuseMap;
fxDiffuseMap = mFX->GetVariableByName("gDiffuseMap")->AsShaderResource();

一旦我們獲得了效果的Texture2D對象的指針,我們可以通過C ++接口來更新它,如下所示:

// Set the C++ texture resource view to the effect texture variable.
fxDiffuseMap->SetResource(mDiffuseMapSRV);

與其他效應變量一樣,如果我們需要在繪製調用之間改變它們,我們必須調用Apply:

// set crate texture
fxDiffuseMap->SetResource(mCrateMapSRV);
pass->Apply(0, md3dImmediateContext);
DrawCrate();

// set grass texture
fxDiffuseMap->SetResource(mGrassMapSRV);
pass->Apply(0, md3dImmediateContext);
DrawGrass();

// set brick texture
fxDiffuseMap->SetResource(mBrickMapSRV);
pass->Apply(0, md3dImmediateContext);
DrawBricks();

紋理圖集可以提高性能,因爲它可以通過一次繪製調用來繪製更多的幾何圖形。例如,假設我們使用瞭如圖8.3所示的紋理地圖集,它包含板條箱,草地和磚塊紋理。然後通過將每個對象的紋理座標調整到相應的子紋理,我們可以在一個繪製調用中繪製幾何圖形(假設每個對象不需要改變其他參數):

// set texture atlas
fxDiffuseMap->SetResource(mAtlasSRV);
pass->Apply(0, md3dImmediateContext);
DrawCrateGrassAndBricks();

由於繪製調用有開銷,所以希望用這樣的技術來最小化它們。

NOTE:紋理資源實際上可以被任何着色器(頂點,幾何或像素着色器)使用。 現在,我們只是在像素着色器中使用它們。 正如我們所提到的,紋理本質上是特殊的數組,所以不難想象數組數據在頂點和幾何着色器程序中也可能是有用的。

8.4 濾波器

8.4.1放大倍率

紋理貼圖的元素應該被認爲是連續圖像中的離散顏色樣本;不應該被認爲是矩形區域。所以問題是:如果我們的紋理座標(u,v)與texel點之一不一致,會發生什麼?這可能發生在以下情況。假設玩家放大場景中的牆壁,使牆壁覆蓋整個屏幕。例如,假設顯示器分辨率爲1024×1024,牆壁的紋理分辨率爲256×256。這導致紋理放大 - 我們試圖用幾個像素來覆蓋許多像素。在我們的例子中,每個紋理點之間有四個像素。當頂點紋理座標在三角形內插時,每個像素將被賦予一對唯一的紋理座標。因此,將會有紋理座標與像素點之一不一致的像素。給定紋理上的顏色,我們可以用插值近似紋理像素間的顏色。插補圖形硬件有兩種支持方法:常量插值和線性插值。一般情況下總是使用線性插值。

圖8.5說明了1D中的這些方法:假設我們有256個樣本的一維紋理和一個內插紋理座標u = 0.126484375。 這個歸一化的紋理座標是指0.126484375×256 = 32.38的紋理。 當然,這個值位於我們的兩個texel樣本之間,所以我們必須使用插值來逼近它。

2D線性插值被稱爲雙線性插值,如圖8.6所示。 給定四個紋元之間的一對紋理座標,我們在u方向上進行兩個1D線性插值,然後在v方向上進行一個1D插值。

8-5
圖8.5 (a)給定紋理點,我們構造一個分段常數函數來逼近紋理點之間的值; 這有時被稱爲最近鄰點採樣,因爲使用了最近的texel點的值。 (b)給定紋理點,我們構造一個分段線性函數來逼近紋理點之間的值。

8-6
圖8.6。 這裏我們有四個texel點cijcij+1ci+1jci+1j+1 。 我們要用插值逼近這四個紋理點之間的c的顏色, 在這個例子中,c位於cij 的右邊0.75個單位和cij 的0.38個單位。 我們首先在頂部兩種顏色之間進行一維線性插值來獲得cT 。 同樣,我們在底部兩種顏色之間進行一維線性插值以得到cB 。 最後,我們在cTcB 之間做一維線性插值得到c。

圖8.7顯示了常數和線性插值之間的差異。 正如你所看到的,常數插值會創建塊狀圖像特性。線性插值更平滑,但仍不會像我們有實際數據(例如,更高分辨率的紋理)而不是通過插值導出的數據一樣好。
8-7
圖8.7 我們放大帶有箱子紋理的立方體,使縮放發生。在左邊我們使用常量插值,這會導致塊狀外觀; 因爲插值函數具有不連續性(圖8.5a),這使得變化突然而不平滑。 在右側,我們使用線性差值,由於插值函數的連續性,這使圖像更平滑。

關於這個討論的一點需要注意的是,在虛擬眼睛可以自由移動和探索的交互式3D程序中,沒有真正的放大的方法。從一定距離,紋理看起來很好,但隨着眼睛離靠近,就會開始分解。有些遊戲限制了虛擬眼睛接近表面以避免過度放大。使用更高分辨率的紋理可以改善該狀況。

NOTE:在紋理的情況下,使用常量插值來查找紋理元素之間紋理座標的紋理值也稱爲點過濾。並且使用線性插值來查找紋元之間的紋理座標的紋理值也稱爲線性濾波。點和線性過濾是Direct3D使用的術語。

8.4.2縮小

縮小與放大相反。縮小中大量紋理像素被映射到少量像素。例如,有一個256×256紋理貼圖的牆。看着牆壁的眼睛不停地往後移動,使牆壁變得越來越小,直到屏幕上只覆蓋了64×64像素。所以現在我們有256×256像素映射到64×64的屏幕像素。在這種情況下,像素的紋理座標通常不會與紋理貼圖的任何紋理元素重合,所以常數和線性插值濾波器仍然適用於縮小情況。還有更多方法可以完成縮小。直觀地說,256×256紋理像素的平均下采樣應該被降低到64×64。mipmapping技術提供了一個有效的近似值,但是犧牲了一些額外的內存。在初始化時(或資產創建時),紋理的較小版本是通過對圖像進行降採樣來創建一個mipmap鏈(見圖8.8)。因此,平均工作是爲mipmap大小預先計算的。在運行時,圖形硬件將根據程序員指定的mipmap設置執行兩種不同的操作:

8-8
圖8.8 一連串的mipmap; 每個連續的mipmap是以前的mipmap細節級別的每個維度的一半大小,直到1×1。

1.選取並使用最適合紋理的投影屏幕幾何分辨率的mipmap級別,根據需要應用常量或線性插值。這就是所謂的mipmap的點過濾,因爲它就像常量插值 - 你只需選擇最接近的mipmap級別並將其用於紋理化。
2.挑選最接近紋理投影屏幕幾何分辨率的兩個最接近的mipmap級別(一個會更大,一個會小於屏幕幾何分辨率)。接下來,對這兩個mipmap級別應用常量或線性過濾,以便爲每個紋理顏色生成紋理顏色。最後,在這兩個紋理顏色結果之間進行插值。這就是所謂的mipmap的線性濾波,因爲它就像線性插值 - 在兩個最接近的mipmap級別之間進行線性插值。通過從mipmap鏈中選擇最佳的紋理細節級別,大大減少了縮小量。

8.4.2.1 創建Mipmap

Mipmap級別可以由藝術家直接創建,也可以通過過濾算法創建。

一些像.DDS(DirectDraw Surface格式)這樣的圖像文件格式可以直接在數據文件中存儲mipmap級別;在這種情況下,數據只需要被讀取,不需要運行時來計算mipmap級別。 DirectX紋理工具可以生成紋理的mipmap鏈,並將其導出到.DDS文件。如果圖像文件不包含完整的mipmap鏈,則D3DX11CreateShaderResourceViewFromFileD3DX11CreateTextureFromFile將使用某種指定的過濾算法(請參閱D3DX11_IMAGE_LOAD_INFO,尤其是SDK文檔中的MipFilter數據成員)爲您創建mipmap鏈。因此我們看到mipmapping基本上是自動的。如果源文件還沒有包含,D3DX11函數會自動爲我們生成一個mipmap鏈。只要啓用了mipmapping,硬件將在運行時選擇正確的mipmap級別。

NOTE:有些下采樣時一個通用的過濾算法不保留我們想要的細節。例如,在圖8.8中,寫在箱子上的文字“Direct 3D”在較低的mip級別上變得模糊。如果這不可接受,藝術家總是可以手動創建/調整mip級別以保持重要的細節。

8.4.3各向異性過濾

另一種可以使用的濾波器稱爲各向異性濾波。 此濾鏡有助於減輕多邊形的法線矢量與相機外觀矢量之間的角度較寬時(例如,當多邊形與視圖窗口正交時)發生的失真。此濾鏡是最昂貴的,但可以付出代價 糾正失真僞影。 圖8.9顯示了比較各向異性濾波和線性濾波的截圖。

8-9
圖8.9 板條箱的頂面幾乎與觀察窗正交。 (左)使用線性過濾,箱子的頂部嚴重模糊。 (右)各向異性過濾在從這個角度渲染箱子的頂面方面做得更好。

8.5採樣紋理

我們看到一個Texture2D對象表示效果文件中的紋理。但是,另一個與紋理相關聯的對象稱爲SamplerState對象(或採樣器)。採樣器對象是我們定義過濾器以用於紋理的地方。 這裏有些例子:

// Use linear filtering for minification, magnification, and mipmapping.
SamplerState mySampler0
{
    Filter = MIN_MAG_MIP_LINEAR;
};
// Use linear filtering for minification, point filtering for magnification,
// and point filtering for mipmapping.
SamplerState mySampler1
{
    Filter = MIN_LINEAR_MAG_MIP_POINT;
};
// Use point filtering for minification, linear filtering for magnification,
// and point filtering for mipmapping.
SamplerState mySampler2
{
    Filter = MIN_POINT_MAG_LINEAR_MIP_POINT;
};
// Use anisotropic interpolation for minification, magnification,
// and mipmapping.
SamplerState mySampler3
{
    Filter = ANISOTROPIC;
    MaxAnisotropy = 4;
};

請注意,對於各向異性過濾,我們必須指定最大的各向異性,這是一個從1到16的數字。較大的值更昂貴,但可以給出更好的結果。您可以從這些示例中找出其他可能的排列,或者可以在SDK文檔中查找D3D11_FILTER枚舉類型。我們將在本章稍後看到其他屬性與採樣器相關聯,但現在這是我們第一個演示所需要的。

現在,在像素着色器中給出一對像素的紋理座標,我們實際上使用以下語法對紋理進行採樣:

// Nonnumeric values cannot be added to a cbuffer.
Texture2D gDiffuseMap;
SamplerState samAnisotropic
{
    Filter = ANISOTROPIC;
    MaxAnisotropy = 4;
};
struct VertexOut
{
    float4 PosH : SV_POSITION;
    float3 PosW : POSITION;
    float3 NormalW : NORMAL;
    float2 Tex : TEXCOORD;
};
float4 PS(VertexOut pin, uniform int gLightCount) : SV_Target
{
    float4 texColor = gDiffuseMap.Sample(samAnisotropic, pin.Tex);
    …

正如您所看到的,要對紋理進行採樣,我們使用Texture2D :: Sample方法。 我們傳遞一個SamplerState對象作爲第一個參數,然後傳入第二個參數的像素(u,v)紋理座標。 此方法使用SamplerState對象指定的過濾方法,從指定的(u,v)點的紋理貼圖中返回內插的顏色。

NOTE:HLSL類型SamplerState鏡像接口ID3D11SamplerState。 採樣器狀態也可以使用ID3DX11EffectSamplerVariable :: SetSampler在應用程序級別進行設置。 請參閱D3D11_SAMPLER_DESCID3D11Device :: CreateSamplerState。與渲染狀態一樣,採樣器狀態應該在初始化時創建。

8.6紋理和材料

爲了將紋理整合到我們的材質/照明系統中,通常使用環境和漫射照明術語來調整紋理顏色,但不能使用鏡面光照術語(這通常稱爲“用延遲添加進行調製”):

// Modulate with late add.
litColor = texColor*(ambient + diffuse) + spec;

這種修改提供了每個像素的環境和漫射材料值,其提供比每個對象材料更精細的分辨率,因爲許多紋理元素通常覆蓋三角形。也就是說,每個像素都會得到內插的紋理座標(u,v); 然後使用這些紋理座標對紋理進行採樣以獲得對該像素的材料描述有貢獻的顏色。

8.7箱子DEMO

我們現在回顧一下將一個箱子紋理添加到一個立方體的關鍵點(如圖8.1所示)。

8.7.1指定紋理座標

GeometryGenerator :: CreateBox生成框的紋理座標,以便將整個紋理圖像映射到框的每個面上。爲了簡潔起見,我們只顯示前面,後面和上面的頂點定義。還要注意,我們省略了頂點構造函數中法線和切線向量的座標(紋理座標用粗體表示)。

void GeometryGenerator::CreateBox(float width, float height, float depth,
MeshData& meshData)
{
    Vertex v[24];

    float w2 = 0.5f*width;
    float h2 = 0.5f*height;
    float d2 = 0.5f*depth;

    // Fill in the front face vertex data.
    v[0] = Vertex(-w2, -h2, -d2, …, 0.0f, 1.0f);
    v[1] = Vertex(-w2, +h2, -d2, …, 0.0f, 0.0f);
    v[2] = Vertex(+w2, +h2, -d2, …, 1.0f, 0.0f);
    v[3] = Vertex(+w2, -h2, -d2, …, 1.0f, 1.0f);

    // Fill in the back face vertex data.
    v[4] = Vertex(-w2, -h2, +d2, …, 1.0f, 1.0f);
    v[5] = Vertex(+w2, -h2, +d2, …, 0.0f, 1.0f);
    v[6] = Vertex(+w2, +h2, +d2, …, 0.0f, 0.0f);
    v[7] = Vertex(-w2, +h2, +d2, …, 1.0f, 0.0f);

    // Fill in the top face vertex data.
    v[8] = Vertex(-w2, +h2, -d2, …, 0.0f, 1.0f);
    v[9] = Vertex(-w2, +h2, +d2, …, 0.0f, 0.0f);
    v[10] = Vertex(+w2, +h2, +d2, …, 1.0f, 0.0f);
    v[11] = Vertex(+w2, +h2, -d2, …, 1.0f, 1.0f);

參考圖8.3,如果你需要幫助,看看爲什麼用這種方式指定紋理座標。
8.7.2創建紋理
我們在初始化時從文件(技術上來說是着色器資源視圖到紋理)創建紋理,如下所示:

// CrateApp data members
ID3D11ShaderResourceView* mDiffuseMapSRV;
bool CrateApp::Init()
{
    if(!D3DApp::Init())
        return false;
    // Must init Effects first since InputLayouts depend
    // on shader signatures.
    Effects::InitAll(md3dDevice);
    InputLayouts::InitAll(md3dDevice);

    HR(D3DX11CreateShaderResourceViewFromFile(md3dDevice,
        L"Textures/WoodCrate01.dds", 0, 0, &mDiffuseMapSRV, 0));

    BuildGeometryBuffers();

    return true;
}

8.7.3設置紋理

紋理數據通常在像素着色器中進行訪問。爲了使像素着色器能夠訪問它,我們需要將紋理視圖(ID3D11ShaderResourceView)設置爲.fx文件中的Texture2D對象。如下:

// Member of BasicEffect.
ID3DX11EffectShaderResourceVariable* DiffuseMap;
// Get pointers to effect file variables.
DiffuseMap = mFX->GetVariableByName("gDiffuseMap")->AsShaderResource();
void BasicEffect::SetDiffuseMap(ID3D11ShaderResourceView* tex)
{
    DiffuseMap->SetResource(tex);
}
// [.FX code]
// Effect file texture variable.
Texture2D gDiffuseMap;

8.7.4更新的基本效果

下面是修改後的Basic.fx文件,現在支持紋理(紋理代碼已加粗):

//=====================================================================
// Basic.fx by Frank Luna (C) 2011 All Rights Reserved.
//
// Basic effect that currently supports transformations, lighting,
// and texturing.
//=====================================================================
#include "LightHelper.fx"

cbuffer cbPerFrame
{
    DirectionalLight gDirLights[3];
    float3 gEyePosW;
    float gFogStart;
    float gFogRange;
    float4 gFogColor;
};
cbuffer cbPerObject
{
    float4x4 gWorld;
    float4x4 gWorldInvTranspose;
    float4x4 gWorldViewProj;
    float4x4 gTexTransform;
    Material gMaterial;
};
// Nonnumeric values cannot be added to a cbuffer.
Texture2D gDiffuseMap;
SamplerState samAnisotropic
{
    Filter = ANISOTROPIC;
    MaxAnisotropy = 4;
    AddressU = WRAP;
    AddressV = WRAP;
};
struct VertexIn
{
    float3 PosL : POSITION;
    float3 NormalL : NORMAL;
    float2 Tex : TEXCOORD;
};
struct VertexOut
{
    float4 PosH : SV_POSITION;
    float3 PosW : POSITION;
    float3 NormalW : NORMAL;
    float2 Tex : TEXCOORD;
};
VertexOut VS(VertexIn vin)
{
    VertexOut vout;
    // Transform to world space space.
    vout.PosW = mul(float4(vin.PosL, 1.0f), gWorld).xyz;
    vout.NormalW = mul(vin.NormalL, (float3x3)gWorldInvTranspose);
    // Transform to homogeneous clip space.
vout.PosH = mul(float4(vin.PosL, 1.0f), gWorldViewProj);
    // Output vertex attributes for interpolation across triangle.
    vout.Tex = mul(float4(vin.Tex, 0.0f, 1.0f), gTexTransform).xy;
    return vout;
}
float4 PS(VertexOut pin, uniform int gLightCount, uniform bool gUseTexure) : SV_Target
{
    // Interpolating normal can unnormalize it, so normalize it.
    pin.NormalW = normalize(pin.NormalW);
    // The toEye vector is used in lighting.
    float3 toEye = gEyePosW - pin.PosW;
    // Cache the distance to the eye from this surface point.
    float distToEye = length(toEye);
    // Normalize.
    toEye /= distToEye;
    // Default to multiplicative identity.
    float4 texColor = float4(1, 1, 1, 1);
    if(gUseTexure)
    {
        // Sample texture.
        texColor = gDiffuseMap.Sample(samAnisotropic, pin.Tex);
    }
    //
    // Lighting.
    //
    float4 litColor = texColor;
    if(gLightCount > 0)
    {
        // Start with a sum of zero.
        float4 ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
        float4 diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
        float4 spec = float4(0.0f, 0.0f, 0.0f, 0.0f);
        // Sum the light contribution from each light source.
    [unroll]
    for(int i = 0; i < gLightCount; ++i)
    {
        float4 A, D, S;
        ComputeDirectionalLight(gMaterial, gDirLights[i], pin.NormalW, toEye, A, D, S);
        ambient += A;
        diffuse += D;
        spec += S;
    } 
    // Modulate with late add.
    litColor = texColor*(ambient + diffuse) + spec;
}
    // Common to take alpha from diffuse material and texture.
    litColor.a = gMaterial.Diffuse.a * texColor.a;
    return litColor;
}
technique11 Light1
{
    pass P0
    {
        SetVertexShader(CompileShader(vs_5_0, VS()));
        SetGeometryShader(NULL);
        SetPixelShader(CompileShader(ps_5_0, PS(1, false)));
    }
}
technique11 Light2
{
    pass P0
    {
        SetVertexShader(CompileShader(vs_5_0, VS()));
        SetGeometryShader(NULL);
        SetPixelShader(CompileShader(ps_5_0, PS(2, false)));
    }
}
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, VS()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_5_0, PS(3, false)));
}
}
technique11 Light0Tex
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, VS()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_5_0, PS(0, true)));
}
}
technique11 Light1Tex
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, VS()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_5_0, PS(1, true)));
}
}
technique11 Light2Tex
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, VS()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_5_0, PS(2, true)));
}
}
technique11 Light3Tex
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, VS()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_5_0, PS(3, true)));
}
}

通過使用統一參數gUseTexture,觀察Basic.fx具有紋理和不具有紋理的技術。這樣,如果我們需要渲染一些不需要紋理的東西,我們選擇沒有它的技術,因此,不需要花費紋理的代價。同樣,我們選擇具有所使用光源數量的技術,這樣我們就不用支付我們不需要的額外光照計算成本。

我們沒有討論過的一個常量緩衝區變量是gTexTransform。 該變量用於頂點着色器來轉換輸入紋理座標:

vout.Tex = mul(float4(vin.Tex, 0.0f, 1.0f), gTexTransform).xy;

紋理座標是紋理平面中的二維點。 因此,我們可以像翻譯其他任何一點一樣翻譯,旋轉和縮放它們。 在這個演示中,我們使用單位矩陣變換,以便輸入紋理座標保持不變。 但是,正如我們將在§9.9中看到的那樣,通過轉換紋理座標可以獲得一些特殊的效果。 請注意,要將2D紋理座標轉換爲4×4矩陣,我們將其擴展爲4維矢量:

vin.Tex ---> float4(vin.Tex, 0.0f, 1.0f)

在乘法完成之後,通過丟棄z和w分量將得到的4D向量投回到2D向量。這就是

vout.Tex = mul(float4(vin.Tex, 0.0f, 1.0f), gTexTransform).xy;

8.8地址模式

結合常數或線性插值的紋理定義矢量值函數T(u,v)=(r,g,b,a)。 也就是說,給定紋理座標uv[0,1]2 ,紋理函數T返回一個顏色(r,g,b,a)。 Direct3D允許我們以四種不同的方式(稱爲地址模式)來擴展此函數的域:wrap, border color, clamp, 和 mirror。
1 . wrap:通過在每個整數交點處重複圖像來擴展紋理函數(見圖8.10)。
2 . border color:通過將不在[0,1] 2中的每個(u,v)映射到由程序員指定的某種顏色來擴展紋理函數(參見圖8.11)。
3 . clamp:將不在[0,1]2 中的每個(u,v)映射到顏色Tu0v0 來擴展紋理函數,其中u0v0 是包含(u,v) 在[0,1]2 中(見圖8.12)。
4 .mirror:通過在每個整數交點處映射鏡像來擴展紋理函數(見圖8.13)。

8-10
圖8.10 Warp地址模式

8-11
圖8.11 Border color地址模式

8-12
圖8.12 Clamp地址模式

8-13
圖8.13 mirror地址模式

必須指定一個地址模式(warp模式是默認模式),因此,能定義[0,1]範圍之外的紋理座標。

warp地址模式可能是最常用的; 它允許我們在一些表面上重複平鋪紋理。這有效地使我們能夠在不提供額外數據的情況下增加紋理分辨率(儘管額外的分辨率是重複的)。通過平鋪,通常紋理是無縫的。例子中箱子紋理不是無縫的,因爲你可以清楚地看到重複。但圖8.14顯示了一個重複2×3次的無縫磚結構。

採樣器對象中指定了地址模式。 以下示例用於創建圖8.10-8.13:

SamplerState samTriLinear
{
    Filter = MIN_MAG_MIP_LINEAR;
    AddressU = WRAP;
    AddressV = WRAP;
};
SamplerState samTriLinear
{
    Filter = MIN_MAG_MIP_LINEAR;
    AddressU = BORDER;
    AddressV = BORDER;
    // Blue border color
    BorderColor = float4(0.0f, 0.0f, 1.0f, 1.0f);
};
SamplerState samTriLinear
{
    Filter = MIN_MAG_MIP_LINEAR;
    AddressU = CLAMP;
    AddressV = CLAMP;
};
SamplerState samTriLinear
{
    Filter = MIN_MAG_MIP_LINEAR;
    AddressU = MIRROR;
    AddressV = MIRROR;
};

8-14
圖8.14 磚紋理平鋪2×3次。由於紋理是無縫的,重複模式很難注意到。

NOTE:可以獨立控制u方向和v方向的地址模式。可以試試這個。

8.9變換紋理

紋理座標表示紋理平面中的二維點。因此,我們可以像變換其他任何一點一樣平移,旋轉和縮放它們。以下是一些用於轉換紋理的示例:
1 . 磚的貼圖沿着牆壁平鋪。當前牆壁頂點的紋理座標在[0,1]的範圍內。我們將紋理座標縮放4倍,將它們縮放到[0,4]的範圍,以便紋理將在牆上重複四次。
2 . 我們有云彩紋理在晴朗的藍天上平鋪。通過將紋理座標變換設置爲時間的函數,雲彩在天空活動起來。
3 . 紋理旋轉可以用於類似粒子的效果,如,我們隨着時間的推移旋轉火球紋理。
紋理座標變換就像常規轉換一樣完成。我們指定一個變換矩陣,並將紋理座標向量乘以矩陣。 例如:

// Constant buffer variable
float4x4 gTexMtx;

// In shader program
vOut.texC = mul(float4(vIn.texC, 0.0f, 1.0f), gTexMtx);

注意,由於我們正在處理二維紋理座標,因此我們只關心對前兩個座標進行的轉換。例如,如果紋理矩陣轉換了z座標,則不會影響生成的紋理座標。

8.10紋理的山和水波Demo

在此Demo中,我們添加紋理到地形和水。第一個關鍵問題是在地形上鋪上草地紋理。因爲地形網格是一個很大的表面,所以如果我們簡單地在它上面延伸一個紋理,那麼每個三角形就會包含太少的紋理。換句話說,表面沒有足夠的紋理分辨率; 紋理會被放大。因此,我們重複地面網格上的草地紋理來獲得更高的分辨率。第二個關鍵問題是將水紋理作爲時間的函數在水面網格上滾動。這個使得水更逼真。圖8.15顯示了Demo的截圖。

8-15
圖8.15 “Land Tex”Demo的屏幕截圖

8.10.1網格紋理座標生成

圖8.16顯示了xz平面中的一個m×n 網格和歸一化紋理空間域[01]2 中的對應網格。從圖中可以清楚地看出,第ij 個網格頂點在xz平面上的紋理座標是第ij 個網格頂點在紋理空間中的座標。第ij 個頂點的紋理空間座標是:

uij=jΔuvij=iΔv

顯而易見 Δu=1n1,Δv=1m1
因此,我們使用下面的代碼在GeometryGenerator :: CreateGrid方法中爲網格生成紋理座標:
void GeometryGenerator::CreateGrid(float width, float depth, UINT m, UINT n, MeshData& meshData)
{
    UINT vertexCount = m*n;
    UINT faceCount = (m-1)*(n-1)*2;
    //
    // Create the vertices.
    //
    float halfWidth = 0.5f*width;
    float halfDepth = 0.5f*depth;
    float dx = width / (n-1);
    float dz = depth / (m-1);
    float du = 1.0f / (n-1);
    float dv = 1.0f / (m-1);
    meshData.Vertices.resize(vertexCount);
    for(UINT i = 0; i < m; ++i)
    {
        float z = halfDepth - i*dz;
        for(UINT j = 0; j < n; ++j)
        {
            float x = -halfWidth + j*dx;
            meshData.Vertices[i*n+j].Position =
            XMFLOAT3(x, 0.0f, z);
            meshData.Vertices[i*n+j].Normal =
            XMFLOAT3(0.0f, 1.0f, 0.0f);
            meshData.Vertices[i*n+j].TangentU =
            XMFLOAT3(1.0f, 0.0f, 0.0f);
            // Stretch texture over grid.
            meshData.Vertices[i*n+j].TexC.x = j*du;
            meshData.Vertices[i*n+j].TexC.y = i*dv;
        }
    }
}

8-16
圖8.16 網格頂點vij在xz空間中的紋理座標由uv空間中的第i個網格頂點Tij給出

8.10.2 紋理平鋪

要在地面網格上鋪上草地紋理。但目前,我們已經計算出紋理座標位於單位域[01]2 ; 所以不會發生拼貼。爲了平鋪紋理,我們指定了平鋪地址模式,並使用紋理變換矩陣將紋理座標縮放 5倍。因此,紋理座標被映射到域[0,5]2 ,使得紋理在紋理網格表面上平鋪5×5次:

XMMATRIX grassTexScale = XMMatrixScaling(5.0f, 5.0f, 0.0f);
XMStoreFloat4x4(&mGrassTexTransform, grassTexScale);
… Effects::BasicFX->SetTexTransform(XMLoadFloat4x4(&mGrassTexTransform));
…
activeTech->GetPassByIndex(p)->Apply(0, md3dImmediateContext);
md3dImmediateContext->DrawIndexed(mLandIndexCount, 0, 0);

8.10.3紋理動畫

爲了在水面上滾動水紋理,我們在UpdateScene方法中將紋理平面中的紋理座標設置爲時間的函數。假設每一幀的位移都很小,這就給出了平滑動畫的錯覺。我們使用平鋪地址模式以及無縫紋理,以便我們可以無縫地拼接紋理空間平面周圍的紋理座標。 下面的代碼顯示了我們如何計算水紋理的偏移矢量,以及如何構建和設置水紋理矩陣:

// Tile water texture.
XMMATRIX wavesScale = XMMatrixScaling(5.0f, 5.0f, 0.0f);

// Translate texture over time.
mWaterTexOffset.y += 0.05f*dt;
mWaterTexOffset.x += 0.1f*dt;
XMMATRIX wavesOffset = XMMatrixTranslation(mWaterTexOffset.x, mWaterTexOffset.y, 0.0f);

// Combine scale and translation.
XMStoreFloat4x4(&mWaterTexTransform, wavesScale*wavesOffset);

… Effects::BasicFX->SetTexTransform(XMLoadFloat4x4(&mWaterTexTransform));
…
activeTech->GetPassByIndex(p)->Apply(0, md3dImmediateContext);
md3dImmediateContext->DrawIndexed(3*mWaves.TriangleCount(), 0, 0);

8.11壓縮紋理格式

隨着虛擬世界中紋理數量的增長,GPU內存需求會快速增加(所有這些紋理保留在GPU內存中,以便快速調用它們)。爲了幫助緩解內存壓力,Direct3D支持壓縮紋理格式:BC1,BC2,BC3,BC4,BC5,BC6和BC7:
1 . BC1(DXGI_FORMAT_BC1_UNORM):如果您需要壓縮支持三種顏色通道和一個1位(開/關)alpha分量的格式,請使用此格式。
2 . BC2(DXGI_FORMAT_BC2_UNORM):如果您需要壓縮支持三種顏色通道的格式,並且僅使用一個4位的alpha分量,則使用此格式。
3 . BC3(DXGI_FORMAT_BC3_UNORM):如果您需要壓縮支持三個顏色通道的格式和一個8位alpha分量,請使用此格式。
4 . BC4(DXGI_FORMAT_BC4_UNORM):如果您需要壓縮包含一個顏色通道(例如灰度圖像)的格式,請使用此格式。
5 . BC5(DXGI_FORMAT_BC5_UNORM):如果您需要壓縮支持兩個顏色通道的格式,請使用此格式。
6 . BC6(DXGI_FORMAT_BC6_UF16):使用此格式壓縮HDR(高動態範圍)圖像數據。
7 . BC7(DXGI_FORMAT_BC7_UNORM):使用此格式進行高質量的RGBA壓縮。 特別是,這種格式大大減少了壓縮法線貼圖造成的誤差。

NOTE:壓縮紋理只能用作渲染管線着色器階段的輸入。由於塊壓縮算法與4×4像素塊一起工作,紋理的尺寸必須是4的倍數。

這些格式的優點是可以將它們壓縮存儲在GPU內存中,然後在需要時由GPU實時解壓縮。

如果您有一個包含未壓縮的圖像數據的文件,則可以使用D3DX11CreateShaderResourceViewFromFile函數的pLoadInfo參數,讓Direct3D在加載時將其轉換爲壓縮格式。 例如,參考下面的代碼,加載一個BMP文件:

D3DX11_IMAGE_LOAD_INFO loadInfo;
loadInfo.Format = DXGI_FORMAT_BC3_UNORM;

HR(D3DX11CreateShaderResourceViewFromFile(md3dDevice,
    L"Textures/darkbrick.bmp", &loadInfo, 0, &mDiffuseMapSRV, 0));

// Get the actual 2D texture from the resource view.
ID3D11Texture2D* tex;
mDiffuseMapSRV->GetResource((ID3D11Resource**)&tex);

// Get the description of the 2D texture.
D3D11_TEXTURE2D_DESC texDesc;
tex->GetDesc(&texDesc);

圖8.17a顯示了調試器中texDesc的使用。我們看到它具有所需的壓縮紋理格式。 如果相反,我們爲pLoadInfo參數指定了null,則使用源圖像的格式(圖8.17b),這是未壓縮的DXGI_FORMAT_R8G8B8A8_UNORM格式。

或者,您可以使用DDS(Direct Draw Surface)格式,它可以直接存儲壓縮紋理。 爲此,請將圖像文件加載到位於SDK目錄中的DirectX紋理工具(DxTex.exe):D:\ Microsoft DirectX SDK(2010年6月)\ Utilities \ bin \ x86。 然後進入菜單 - >格式 - >更改表面格式,然後選擇DXT1,DXT2,DXT3,DXT4或DXT5。將文件保存爲DDS文件。這些格式實際上是Direct3D 9壓縮紋理格式,但DXT1與BC1相同,DXT2和DXT3與BC2相同,而DXT4和DXT5與BC3相同。例如,如果我們將文件另存爲DXT1並使用D3DX11CreateShaderResourceViewFromFile加載,那麼紋理的格式將爲DXGI_FORMAT_BC1_UNORM

HR(D3DX11CreateShaderResourceViewFromFile(md3dDevice,
    L"Textures/darkbrickdxt1.dds", 0, 0, &mDiffuseMapSRV, 0));

// Get the actual 2D texture from the resource view.
ID3D11Texture2D* tex;
mDiffuseMapSRV->GetResource((ID3D11Resource**)&tex);

// Get the description of the 2D texture.
D3D11_TEXTURE2D_DESC texDesc;
tex->GetDesc(&texDesc);

8-17
圖8.17 (a)使用DXGI_FO RMAT_BC3_UNO RM壓縮格式創建紋理。 (b)使用DXGI_FORMAT_R8G8B8A8_UNO RM未壓縮格式創建紋理

請注意,如果DDS文件使用其中一種壓縮格式,則可以爲pLoadInfo參數指定null,D3DX11CreateShaderResourceViewFromFile將使用文件指定的壓縮格式。

對於BC4和BC5格式,您可以使用NVIDIA紋理工具(http://code.google.com/p/nvidia-texture-tools/)。對於BC6和BC7格式,DirectX SDK有一個名爲“BC6HBC7EncoderDecoder11”的示例。該程序可用於將紋理文件轉換爲BC6或BC7。 該示例包含完整的源代碼,以便您可以將其集成到您自己的藝術管道中。有趣的是,如果圖形硬件支持計算着色器,則示例使用GPU進行轉換; 這比CPU實現提供了更快的轉換。

8-18
圖8.18 紋理是使用DXGI_FO RMAT_BC1_UNO RM格式創建的

您還可以在DirectX紋理工具中生成mipmap級別(Menu-> Format-> Generate Mip Maps),並將它們保存在DDS文件中。 通過這種方式,mipmap級別被預先計算並且與文件一起存儲,以便它們不需要在加載時被計算(他們只需要被加載)。

存儲在DDS文件中壓縮紋理的另一個好處是它們佔用的磁盤空間也更少。

8.12總結

1.紋理座標用於在紋理上定義一個映射到三角網格的三角形。
2.我們可以使用D3DX11CreateShaderResourceViewFromFile函數從存儲在磁盤上的圖像文件創建紋理。
3.我們可以使用縮小,放大和mipmap濾鏡採樣器狀態來過濾紋理。
4.Direct3D中地址模式定義在[0,1]範圍之外的紋理座標應該如何處理。例如,應該
紋理平鋪,鏡像,夾緊等?
5.紋理座標可以像其他點一樣縮放,旋轉和平移。通過每幀少量增量變換紋理座標實現紋理動畫。
6.通過使用壓縮的Direct3D紋理格式BC1,BC2,BC3,BC4,BC5,BC6或BC7,我們可以節省大量的GPU內存。使用DirectX紋理工具生成格式爲BC1,BC2和BC3的紋理。對於BC4和BC5,您可以使用NVIDIA紋理工具(http://code.google.com/p/nvidia-texture-tools/)。使用SDK“BC6HBC7EncoderDecoder11”樣本生成格式爲BC6和BC7的紋理。

發佈了7 篇原創文章 · 獲贊 7 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章