第七章 光照

如圖7.1左邊,一個沒加光照的球體,右邊一個加光照的球體。可見,左邊的球看起來是平的 - 也許它根本不是一個球,而只是一個二維圓。另一方面,右邊的球體看上去是3D的-照明和陰影幫助我們感知物體的形狀和體積。事實上,我們對世界的視覺感受依賴於光及其與物質的相互作用,因此,產生真實照片的場景的許多問題都與物理上精確的照明模型有關。

當然,模型越精確,計算量越大; 因此必須權衡效果和速度。如,用於電影的3D特效場景十分複雜,並且使用非常逼真的照明模型,因爲電影的幀是預先渲染的,所以他們可以花費數小時或數天來處理幀。遊戲, 另一方面是實時應用,因此,幀需要以至少30幀/秒的速率繪製。

注意,本書中解釋和實現的照明模型主要基於[Möller02]中描述的模型。

目標:
1.對燈光和材料之間的相互作用有一個基本的瞭解。
2.瞭解局部光照與全局光照的區別。
3.掌握我們如何用數學方法描述表面上的一個點的方向是“面向”,以便我們可以確定入射光照到表面的角度。
4.學習如何正確轉換法向量。
5.能夠區分環境光,漫射光和鏡面光。
6.學習如何實現定向燈,點燈和聚光燈。
7.瞭解如何通過控制衰減參數來改變作爲深度函數的光強度。

7.1光和材料的相互作用

使用照明時,不再直接指定頂點顏色; 而是指定材質和燈光,然後應用一個光照方程,根據光線/材料的相互作用計算出頂點顏色。這使對象的更真實的着色(比較圖7.1a和7.1b)。

7-1
圖7.1 (a)未加光照的球體看起來是二維的 (b)加光照的球體看起來是3D的

材質可以被認爲是決定光線如何與物體表面相互作用的屬性。例如,表面反射和吸收的光的顏色,反射率,透明度和光澤度都是組成表面材料的參數。然而在本章中,我們只關心表面反射和吸收的光線的顏色,以及光澤度。

在我們的模型中,光源可以發出不同強度的紅光,綠光和藍光; 通過這種方式,我們可以模擬出多種顏色的光。當光線從光源向外傳播並與物體發生碰撞時,一些光線可能被吸收,一些光線可能被反射(一些光線會通過透明物體,如玻璃,但我們在這裏不考慮透明度)。反射光現在沿着新的路徑傳播,並可能撞擊其他物體,在這些物體上再次吸收和反射一些光線。光線在被完全吸收之前可能撞擊許多物體。一些光線最終會進入眼睛(見圖7.2),並撞擊視網膜上的感光細胞(稱爲視錐細胞和視杆細胞)。

7-2
圖7.2 (a)入射白光的通量。 (b)光照射在圓柱體上,一些光線被吸收,其他光線被散射到眼睛和球體上。(c)從圓柱體向球體反射的光被再次吸收或反射並傳播到眼睛中。(d)眼睛接收到的光線決定了眼睛看到的東西。

根據三色理論(見[Santrock03]),視網膜包含三種感光細胞,分別對應紅,綠和藍色光敏感(有一些重疊)。輸入的RGB光根據光的強度以不同的強度刺激其相應的光感細胞。 當感光細胞被刺激(或不受刺激)時,神經衝動由視神經傳給大腦,大腦根據光受體的刺激在腦中產生圖像。(當然,如果你閉上你的眼睛,受體細胞就不會受到刺激,大腦就會將其記錄爲黑色。)

例如,再次考慮圖7.2。假設圓柱體的材料反射75%的紅光,75%的綠光,吸收其他所有光,球體反射25%的紅光,吸收其他所有光。還假設從光源發出純白光。當光線撞擊圓柱體時,所有的藍光都被吸收,只有75%的紅光和綠光被反射(即中等強度的黃光)。這些光線是散射的 - 有些光線會傳播到眼睛,有些則會傳播到球體。進入眼睛的部分以一半強度主要刺激紅色和綠色的錐形細胞;因此,觀察者將該圓柱視爲黃色的半明亮影像。現在,其他光線向球體傳播並撞擊。球體反射25%紅光,吸收其餘部分;因此稀釋的入射紅光(中高強度紅光)被進一步稀釋並反射,並且所有入射的綠光都被吸收。這剩餘的紅光然後進入眼睛,主要刺激紅色的視錐細胞到一個較低的程度。因此,觀衆看到球體是一個深紅的陰影。

和大多數實時應用一樣,在本書中採用的照明模型被稱爲局部照明模型。在局部模型中,每個物體獨立於另一個物體而被照亮,並且在照明過程中僅考慮從光源直接發出的光(即,從其他場景物體彈起後打在當前被照亮的物體的光是忽略)。圖7.3顯示了這個模型的一個結果。

相反,全局照明模型不僅考慮從光源直接發出的光線,還考慮到從場景中的其他物體反射回來的間接光線。 這些被稱爲全局照明模型,因爲它們在照亮物體時考慮全局場景中的所有事物。全局照明模式通常對於實時遊戲而言過於昂貴(但是非常接近於產生照片真實感的場景)。實時全局照明方法正在進行研究。

7-3
圖7.3 在物理上,牆壁阻擋了燈泡發出的光線,球體在牆的陰影中。 然而,在一個局部照明模型中,球體像牆壁不在那裏一樣點亮

7.2 法向量

面法線是描述多邊形面向的方向(即,與多邊形上的所有點正交)的單位向量; 見圖7.4a。表面法線是與表面上的點的切平面正交的單位矢量;請參見圖7.4b。觀察表面法線確定表面上的一個點“面對”的方向。

對於照明計算,我們需要每個三角形網格定點的表面法線,以便我們可以確定光照在網格表面上點的角度。爲了獲得曲面法線,我們僅在頂點處(即所謂的頂點法線)指定曲面法線。然後,爲了在三角形網格的表面上的每個點上獲得曲面法線近似,這些頂點法線將在光柵化期間在三角形內插(參見§5.10.3和圖7.5)。

NOTE:對每個像素的法線進行插值計算稱爲像素照明或phong照明。 一個便宜但不太精確的方法是在每個頂點進行照明計算。 然後,從頂點着色器輸出每個頂點光照計算的結果,並在三角形的像素內插。 從像素着色器到頂點着色器的移動計算是一個常見的性能優化的質量,有時視覺差異是非常微妙的,使這種優化非常有吸引力。

7-4
圖7.4 (a)面法線正交於面上的所有點。 (b)曲面法線是與曲面上的一個點的切平面正交的向量。

7-5
圖7.5 頂點法線n0和n1在分段頂點點p0和p1處定義。線段內部的點p的法線向量n通過頂點法線之間的線性內插(加權平均)來找到; 即n = n0 + t(n1-n0),其中t是使得p = p0 + t(p1-p0)。儘管爲了簡單起見,我們在一個線段上說明了正常的插值,但是這個想法直接推廣到在三角形上進行插值。

7.2.1計算法向量

爲了找到一個三角形Δp0p1p2 的面法線,我們首先計算兩個位於三角形邊緣上的向量:

u=p1p0v=p2p0

那麼面向量爲:
n=u×v||u×v||

以下是從三角形的三個頂點計算三角形正面(§5.10.2)的面法線的函數。
void ComputeNormal(const D3DXVECTOR3& p0,
                const D3DXVECTOR3& p1,
                const D3DXVECTOR3& p2,
D3DXVECTOR3& out)
{
    D3DXVECTOR3 u = p1 - p0;
    D3DXVECTOR3 v = p2 - p0;
    D3DXVec3Cross(&out, &u, &v);
    D3DXVec3Normalize(&out, &out);
}

對於可微分曲面,我們可以使用微積分來找到曲面上的點的法線。不幸的是,三角網格是不可微分的。通常應用於三角形網格的技術稱爲頂點法線平均。通過對共享頂點v的網格中的每個多邊形的面法線進行平均來找到網格中任意頂點v的頂點法線n。例如,在圖7.6中,網格中的四個多邊形共享頂點v; 因此,v的頂點法線由下式給出:

narg=n0+n1+n2+n3||n0+n1+n2+n3||

7-6
圖7.6 中間頂點由相鄰的四個多邊形共享,所以我們通過對四個多邊形面法線進行平均來逼近中間頂點法線。

在前面的例子中,我們不需要像典型的平均值那樣除以4,因爲我們將結果歸一化。還要注意,可以構建更復雜的平均方案; 例如,可以在權重由多邊形的面積確定的情況下使用加權平均(例如,具有較大面積的多邊形具有比具有較小面積的多邊形更多的權重)。

下面的僞代碼展示瞭如何在給定三角形網格的頂點和索引列表的情況下實現這種平均:

// Input:
// 1. An array of vertices (mVertices). Each vertex has a
// position component (pos) and a normal component (normal).
// 2. An array of indices (mIndices).

// For each triangle in the mesh:
for(UINT i = 0; i < mNumTriangles; ++i)
{
    // indices of the ith triangle
    UINT i0 = mIndices[i*3+0];
    UINT i1 = mIndices[i*3+1];
    UINT i2 = mIndices[i*3+2];
    // vertices of ith triangle
    Vertex v0 = mVertices[i0];
    Vertex v1 = mVertices[i1];
    Vertex v2 = mVertices[i2];
    // compute face normal
    Vector3 e0 = v1.pos - v0.pos;
    Vector3 e1 = v2.pos - v0.pos;
    Vector3 faceNormal = Cross(e0, e1);
    // This triangle shares the following three vertices,
    // so add this face normal into the average of these
    // vertex normals.
    mVertices[i0].normal += faceNormal;
    mVertices[i1].normal += faceNormal;
    mVertices[i2].normal += faceNormal;
}
// For each vertex v, we have summed the face normals of all
// the triangles that share v, so now we just need to normalize.
for(UINT i = 0; i < mNumVertices; ++i)
    mVertices[i].normal = Normalize(&mVertices[i].normal));

7.2.2法向量的轉換

考慮圖7.7a,其中我們有一個與法向量n正交的切向量u=v1v0 。 如果我們應用非均勻縮放變換A,我們從圖7.7b可以看出,變換後的切向量uA=v1Av0A 並不保持與變換的法向量nA正交。

所以我們的問題是這樣的:給定一個變換點和向量(非正態)的變換矩陣A,我們想要找到一個變換矩陣B來變換法向量,使得變換後的切向量與變換的法向量正交(即, uA·nB = 0)。 要做到這一點,首先讓我們從我們知道的事情開始:我們知道法向量n正交於切向量u則有:

u · n =0 法向量與切向量正交
unT = 0 將點積寫爲矩陣乘法
u(AA-1) nT = 0 插入單位矩陣I=AA-1
(uA)(A–1 nT) = 0 矩陣乘法結合律
(uA) ((A–1 nT)T)T = 0 轉置屬性(AT)T=A
(uA) (n(A–1)T)T = 0 轉置屬性(AB)T=BTAT
uA · n(A–1)T = 0
uA · nB = 0 轉換後的正切與方向向量正交

因此,B =(A-1)T(A的逆轉置)在轉換法向矢量方面做了工作,以使它們垂直於其相關的變換的切向量uA.

7-7
圖7.7 (a)轉換前的表面正常。 (b)在x軸上縮放2個單位後,法線不再與表面垂直。 (c)通過縮放變換的逆轉置正確變換的曲面法線。

注意如果矩陣是正交的AT=A1 ,那麼B =(A^{-1})^T =(A^T)^T = A; 也就是說,我們不需要計算逆轉置,因爲A在這種情況下完成了工作。 總之,當通過非均勻或剪切變換來變換法向矢量時,使用逆轉置。

我們在MathHelper.h中實現一個輔助函數來計算逆轉置:

static XMMATRIX InverseTranspose(CXMMATRIX M)
{
    XMMATRIX A = M;
    A.r[3] = XMVectorSet(0.0f, 0.0f, 0.0f, 1.0f);
    XMVECTOR det = XMMatrixDeterminant(A);
    return XMMatrixTranspose(XMMatrixInverse(&det, A));
}

我們從矩陣中清除任何翻譯,因爲我們使用逆轉置來轉換向量,翻譯僅適用於點。 然而,從§3.2.1我們知道設置w = 0的矢量(使用齊次座標)防止了矢量被翻譯修改。 因此,我們不需要將矩陣中的翻譯歸零。 問題是如果我們想連接逆轉置和不包含非均勻縮放的另一個矩陣,比如視圖矩陣(A-1TV,(A-1T“第4列中的轉置轉換泄漏” 進入產品矩陣造成錯誤。 因此,爲了避免這種錯誤,我們將翻譯作爲預防措施進行歸零。 正確的方法是用((AV)-1)T來轉換法線。以下是一個縮放和平移矩陣的例子,以及第四列不是[0,0,0,1]的逆轉置看起來如何:

A=[100000.500000.501111](A1)T=[100000.500000.501111]

NOTE:即使進行逆轉置轉換,法向矢量也可能會丟失其單位長度; 因此,轉型後可能需要重新歸一化。

7.3蘭伯特的“餘弦法”

直射表面某點的光比斜射表面某點的光更強烈; 見圖7.8。 考慮一小束橫截面積爲dA的入射光。

解決方案是想出一個函數,根據頂點法線和光線矢量的對齊返回不同的強度。(注意,光矢量是從表面到光源的矢量;也就是說,光矢量的方向與光線傳播的方向相反)。當頂點法線和光線矢量完全對齊時,函數應該返回最大強度( 即它們之間的角度θ是0°),並且隨着頂點法線與光線矢量之間的角度變化,其強度應該平滑地減小。 如果θ> 90°,那麼光照到一個表面的背面,所以我們設置強度爲零。蘭伯特的餘弦定律給出了我們所尋求的功能,這是由

f(θ)=max(cosθ,0)=max(L·n,0)

7-8
圖7.8 考慮一個小面積元素dA。(a)當法向矢量n和光矢量L對齊時,區域dA接收最多的光。(b)隨着n和L之間的角度θ增加,面積dA受到較少的光線(如經過表面dA的光線所示)。

其中L和n是單位矢量。 圖6.9顯示了f(θ)的曲線圖,看看0.0到1.0(即0%到100%)範圍內的強度是如何隨θ變化的。

7-9
圖7.9 對於-2≤θ≤2,函數f(θ)= max(cosθ,0)= max(L·n,0)的繪圖。注意π/2≈1.57。

7.4 散射光

考慮一個粗糙的表面,如圖7.10所示。當光照在這樣一個表面上的一個點上時,光線會以各種隨機方向散射; 這被稱爲漫反射。在我們對這種光/表面相互作用進行近似建模時,我們規定光在表面上方的所有方向均勻散射; 因此,無論視點位置如何,反射光都會到達。因此,我們不需要考慮視點(即漫射照明計算與視點無關),並且無論視點如何,表面上的點的顏色總是看起來相同。

我們將漫射照明的計算分爲兩部分。對於第一部分,我們指定漫反射光的顏色和漫反射材料的顏色。漫射材料指定表面反射和吸收的入射漫射光的量; 這是用分量色彩乘法處理的。例如,假設表面上的某個點反射了50%的入射紅光,100%綠光和75%藍光,而入射光的顏色是80%強度的白光。因此,入射漫射光的顏色由nd=0.8,0.8,0.8 給出,漫射材料的顏色由md=0.5,1.0,0.75 給出。那麼從該點反射的光量由下式給出:

D=ldmd=(0.8,0.8,0.8)(0.5,1.0,0.75)=(0.4,0.8,0.6)

爲了完成漫反射照明計算,我們只包括蘭伯特(Lambert)的餘弦定律(它根據表面法線和光線矢量之間的角度來控制表面接收的原始光線的多少)。假設ld 是漫射光的顏色,md 是漫射材料的顏色,kd=maxL·n0 ,其中L是光矢量,n是表面法線。 那麼從一個點反射的漫反射光量由下式給出:
(()cd=kd·ldmd=kdDeq.7.1)

7-10
圖7.10 入射光線在散射表面時隨機散射。 這個想法是,微觀層面的表面是粗糙的。

7.5環境光照

如前所述,我們的照明模型並沒有考慮在場景中反射其他物體的間接光。然而,我們在現實世界中看到的光線是間接的。例如,與房間連接的走廊可能與房間內的光源不在直接的視線中,但是燈光從房間的牆壁反彈,其中一些可能使其進入走廊,從而使其照亮 提高一點。作爲第二個例子,假設我們坐在桌子上有一個茶壺的房間裏,房間裏有一個光源。茶壺只有一面在光源的直線上, 不過,茶壺的背面不會變黑。這是因爲有些燈光散落在房間的牆壁或其他物體上,最終撞擊茶壺的背面。

爲了對這種間接光線進行破解,我們給光照方程引入一個環境項:

A=lama

顏色la 指定表面從光源接收到的間接(環境)光的總量。環境材料顏色ma 指定表面反射和吸收的入射環境光的量。所有的環境光線都是一點一點地使物體變亮 - 根本沒有真正的物理計算。間接的光在場景周圍散射和反射很多次,以致它在各個方向上均勻地擊中對象。

將環境條件與漫射項組合在一起,我們的新照明方程如下所示:

(()LitColor=lama+kd·ldmd=A+kdDeq.7.2)

7.6 鏡面反射

考慮一個光滑的表面,如圖7.11所示。當光線照射到這樣一個表面時,光線通過一個反射錐體在一個大致方向上急劇反射; 這被稱爲鏡面反射。與漫射光相比,鏡面反射光可能不會進入眼睛,因爲它反射的方向是特定的; 鏡面光照計算與視點有關。這意味着當眼睛在場景中移動時,它接收到的鏡面光量將會改變。

7-11
圖7.11 入射光線由I表示。鏡面反射不是在所有方向上散射,而是反射在一個通用的反射錐中,我們可以用一個參數來控制它的大小。 如果v在錐體內,則眼睛接收到鏡面光; 否則,不接收。 越靠近反射矢量r,眼睛接收的鏡面反射光越多。

鏡面光反射的圓錐由相對於反射向量r的角度ϕmax 定義。直觀地說,基於反射矢量r和視點矢量v=(EP)||EP|| (即,從表面點P到眼睛位置E的單位矢量)之間的角度ϕ 以如下方式改變鏡面光強度是有意義的:我們規定:當ϕ=0 時鏡面光強度達到最大,並且隨着ϕ 達到ϕmax 而平滑地減小到零。爲了在數學上對此進行建模,我們修改了Lambert餘弦定律中使用的函數。圖7.12顯示了p≥1的不同冪的餘弦函數圖。本質上,通過選擇不同的p,我們間接地控制光強度下降到零的圓錐角ϕmax 。參數p可以用來控制表面的光澤;也就是說,高度拋光的表面將具有比光澤度較低的表面更小的反射率錐(光反射更強烈)。所以,比磨砂表面你會在光澤表面使用一個更大的p。

7-12
圖7.12 餘弦函數具有p≥1的不同冪的函數圖。

請注意,因爲v和r是單位向量,所以我們有cos(ϕ)=v·r
從一個點進入眼睛反射的鏡面反射光量由下式給出:
cs=ks·lsms=ksS

這裏
ks={max(v·r,0)pL·n>00L·n0

顏色ls指定光源發射的鏡面光量。鏡面材質顏色ms指定表面反射的鏡面光線的數量。因子ks根據r和v之間的角度來調整鏡面反射光的強度。圖7.13顯示了一個表面可能不接收漫射光(L·n <0),但是可以接收鏡面反射光。然而,如果表面沒有接收到漫射光,那麼表面就不會接收鏡面反射光,所以我們在這種情況下設置ks=0
7-13
圖7.13 即使光照在表面的背面,眼睛也可以接收鏡面光。這是不正確的,所以我們必須檢測到這種情況,並在這種情況下設置ks=0

NOTE:鏡面光焦度p應該總是大於或等於1。

我們新的光照模型爲:

LitColor=lama+kd·ldmd+ks·lsms=A+kdD+ksSkd=max(L·n,0)ks={max(v·r,0)pL·n>00L·n0

Note:反射向量由下式給出:r = I - 2(n·I)n; 見圖7.14 (假設n是一個單位向量)然而,我們實際上可以使用HLSL內在反射函數在着色器程序中爲我們計算r。

觀察到入射光的方向即入射光的方向(即光矢量L的相反方向)。

7-14
圖7.14 幾何反射

7.7 簡要介紹

在我們的模型中,光源發出三種不同的光:
1.環境光:模擬間接照明。
2.漫射光:模擬相對粗糙表面的直接照明。
3.鏡面光:模擬相對光滑表面的直接照明。
相應地,表面點具有與其相關的以下材料屬性:
1.環境材料:表面反射和吸收的環境光量。
2.漫射材料:表面反射和吸收的漫射光量。
3.鏡面反射材料:表面反射和吸收的鏡面反射光量。
4.鏡面指數:在鏡面光照計算中使用的指數,它控制反射的錐體,從而表面是多麼光亮。 錐體越小,表面越光滑/光亮。

將光分解成三個組件的原因是爲了靈活性; 藝術家有幾個自由度來調整以獲得所需的輸出。 圖7.15顯示了這三個組件如何一起工作。

7-15
圖7.15 (a)只有環境光線的球體才能均勻地照亮它。(b)環境照明和漫射照明相結合。 由於蘭伯特的餘弦定律,現在有從光明到黑暗的平滑過渡。(c)環境照明,漫射照明和鏡面照明。 高光照明產生鏡面反光。

7.8 指定材料

我們應該如何指定物質價值? 材質值可能在表面上有所不同;也就是說,表面上的不同點可能有不同的材料值(見圖7.16)。例如,考慮汽車模型,其中框架,窗戶,燈光和輪胎反射和吸收光線的方式不同,因此材料值需要在汽車表面上變化。

7-16
圖7.16 汽車網格模型本劃分爲五個材質屬性組。

爲了近似地模擬這種變化,一種解決方案可能是在每個頂點基礎上指定材料值。 這些每個頂點材質將在柵格化過程中在三角形內插,爲三角形網格表面上的每個點提供材質值。但是,正如我們在第6章的“Hills”演示中看到的,每個頂點顏色仍然太粗糙,不能真實地模擬細節。而且,每個頂點顏色爲我們的頂點結構增加了額外的數據,我們需要有工具來繪製每個頂點顏色。相反,普遍的解決方案是使用紋理映射,這將不得不等到下一章。同時,我們允許以平局的頻率進行重大變更。也就是說,我們將材質值設置爲常量緩衝區的成員,隨後所有繪製的幾何將使用該材質,直到在繪製調用之間進行更改。下面的僞代碼顯示了我們如何繪製汽車:

Set Primary Lights material to constant buffer
Draw Primary Lights geometry
Set Secondary Lights material to constant buffer
Draw Secondary Lights geometry
Set Tire material to constant buffer
Draw Tire geometry
Set Window material to constant buffer
Draw Windows geometry
Set Car Body material to constant buffer
Draw car body geometry

材料的結構在LightHelper.h 中定義,類似如下結構:

struct Material
{
    Material() { ZeroMemory(this, sizeof(this)); }
    XMFLOAT4 Ambient;
    XMFLOAT4 Diffuse;
    XMFLOAT4 Specular; // w = SpecPower
    XMFLOAT4 Reflect;
};

現在忽略反射成員; 稍後當我們模擬像鏡子一樣的反射,並且需要指定一個表面如何作用的鏡子時,將會使用它。另請注意,我們將鏡面冪指數p嵌入到鏡面材質顏色的第四個分量中。這是因爲照明不需要alpha分量,所以我們不妨使用空槽來存儲一些有用的東西。散射材料的alpha分量也將用於後面章節中的alpha混合。

最後,我們提醒讀者,我們需要在三角形網格表面的每個點上的法向量,以便我們可以確定光照到網格表面上的一個點的角度(對於朗伯余弦定律)。爲了在三角形網格的每個表面上獲得法向矢量逼近,我們在頂點級別指定法線。在光柵化過程中,這些頂點法線將在整個三角形內插入。

到目前爲止,我們已經討論了光的組成部分,但是我們沒有討論特定種類的光源。接下來的三部分將介紹如何實現平行光點,點光源和聚光燈。

7.9 平行光

平行光(或定向光)近似於非常遠的光源。因此,我們可以將所有入射光線近似爲平行(圖7.17)。平行光源由向量定義,該向量指定光線傳播的方向。由於光線是平行的,它們都使用相同的方向矢量。光矢量的目標是使光線行進的方向相反。一個真正的定向光源的常見例子是太陽(圖7.18)。定向光的方程正如7.3。

7-17
圖7.17 照在表面的平行光線

7-18
圖7.18 該圖不是按比例繪製的,但如果您在地球上選擇一個較小的表面區域,撞擊該區域的光線大致平行。

7.10 點光源

點光源的一個很好的物理例子是燈泡。它在各個方向上都呈球形輻射狀(圖7.19)。特別是對於任意的點P,存在從點光源位置Q向該點傳播的光線。像往常一樣,我們將光矢量定義爲相反的方向; 即從點P到點光源的方向Q:

L=QP||QP||

本質上,點光源和平行光源之間唯一的區別在於光源矢量是如何計算的 - 點光源從一個點到另一個點是不同的,但是對於平行光源而言,它保持不變。
7-19
圖7.19 點光源向各個方向輻射; 特別是對於任意點,存在從點光源Q朝向P的光線。

7.10.1 衰減

在物理上,根據平方反比法則,光強度作爲距離的函數減弱。也就是說,遠離光源距離d處的光強度由下式給出:

I(d)=I0d2

其中 I0 是距離光源距離 d=1 處的光強度。但是,這個公式並不總是給出完美的結果。因此,我們不再擔心物理準確性,而是爲藝術家/程序員提供了一些更爲一般的公式,使藝術家/程序員能夠控制一些參數(即藝術家/程序員用不同的參數值進行實驗,直到他對結果滿意爲止)。用來衡量光照強度的典型公式是:
I(d)=I0a0+a1d+a2d2

我們調用a0,a1a2 衰減參數,並由藝術家或程序員提供。例如,如果實際上希望光強隨反距離減弱,則設a0=0,a1=1,a2=0 。如果希望跟距離的平方成反比,則設a0=0,a1=0,a2=1

考慮衰減情況則有:

LitColor=A+kdD+ksSa0+a1d+a2d2

請注意,衰減不會影響環境條件,因爲環境條件用於模擬已反彈的間接光源。

7.10.2 範圍

對於點光源,我們包含一個額外的範圍參數。距離光源的距離大於該範圍的點不接收來自該光源的任何光。此參數對於將燈光定位到特定區域很有用。儘管衰減參數隨着距離而減弱了光強度,但是能夠明確地定義光源的最大範圍仍然是有用的。範圍參數對於着色器優化也很有用。正如我們將很快看到的那樣,在我們的着色器代碼中,如果點超出範圍,那麼我們可以儘早返回並跳過具有動態分支的光照計算。範圍參數不會影響平行光源,這種光源可以模擬很遠的光源。

7.11 聚光燈

聚光燈的一個很好的物理例子是手電筒。從本質上講,聚光燈的位置爲Q,瞄準方向爲d,並通過一個圓錐體發射光線(見圖7.20)。

7-20
圖7.20 聚光燈具有位置Q,瞄準方向d,並通過角度爲ϕmax 的錐體輻射光。

爲了實現聚光燈,我們開始與點光源一樣:光矢量由下式給出:
L=QP||QP||

其中P是點的位置,Q是聚光燈的位置。從圖7.20可以看出,當且僅當 -L和d之間的角度ϕ 小於錐角ϕmax 時,P在聚光燈錐體內部(並因此接收光)。此外,聚光燈錐體內的所有光線不應該是相等的強度;圓錐中心的光線應該是最強的,當ϕ 從0增加到ϕmax 時,光線強度應該消失。

那麼我們如何將強度衰減控制爲ϕ 的函數呢?我們又如何控制聚光燈錐的大小呢?那麼,我們可以玩與反射的鏡面反射相同的方法。也就是說,我們使用這個函數:

kspot(ϕ)=max(cosϕ,0)s=max(L·d,0)s

所以聚光方程就像點光方程一樣,除了我們乘以聚光因子來根據光點在哪裏相對於聚光錐來標定光強度:
(eq. 7.5)LitColor=kspot(A+kdD+ksSa0+a1d+a2d2)

Note:通過比較公式7.4和7.5,我們看到聚光燈比點光源更昂貴,因爲我們需要計算kspot因子並乘以它。類似地,通過比較公式7.3和7.4,我們看到點光比定向光更昂貴,因爲需要計算距離d(實際上這非常昂貴,因爲距離涉及平方根操作),並且我們需要劃分總的來說,定向燈是最便宜的光源,其次是點光源。聚光燈是最昂貴的光源。

7.12 實現

在LightHelper.h中,我們定義了以下結構來表示我們支持的三種燈。

struct Directional Light
{
    DirectionalLight() { ZeroMemory(this, sizeof(this)); }
    XMFLOAT4 Ambient;
    XMFLOAT4 Diffuse;
    XMFLOAT4 Specular;
    XMFLOAT3 Direction;
    float Pad; // Pad the last float so we can
        // array of lights if we wanted.
};
struct Point Light
{
    PointLight() { ZeroMemory(this, sizeof(this)); }

    XMFLOAT4 Ambient;
    XMFLOAT4 Diffuse;
    XMFLOAT4 Specular;

    // Packed into 4D vector: (Position, Range)
    XMFLOAT3 Position;
    float Range;

    // Packed into 4D vector: (A0, A1, A2, Pad)
    XMFLOAT 3 Att;
    float Pad; // Pad the last float so we can set an
        // array of lights if we wanted.
};
struct SpotLight
{
    SpotLight() { ZeroMemory(this, sizeof(this)); }

    XMFLOAT4 Ambient;
    XMFLOAT4 Diffuse;
    XMFLOAT4 Specular;

    // Packed into 4D vector: (Position, Range)
    XMFLOAT3 Position;
    float Range;

    // Packed into 4D vector: (Direction,Spot)XMFLOAT3 Direction;
    float Spot;

    // Packed into 4D vector: (Att, Pad)
    XMFLOAT 3 Att;
    float Pad; // Pad the last float so we can set an
        // array of lights if we wanted.
};

1. Ambient:環境發出的環境光
2. Diffuse:環境發出的漫射光
3. Specular:鏡面反射
4. Direction:光的方向
5. Position:光的位置
6. Range:光的範圍。超出該範圍的點沒有光照。
7. Attenuation:以控制光強隨距離下降的格式(a0,a1,a2)存儲三個衰減常數。
8. Spot:用來控制聚光燈錐的指數。

我們將在下一節討論“pad”變量和“打包”格式的必要性。LightHelper.fx文件定義了鏡像這些文件的結構:

struct DirectionalLight
{
    float4 Ambient;
    float4 Diffuse;
    float4 Specular;
    float3 Direction;
    float pad;
};
struct PointLight
{
    float4 Ambient;
    float4 Diffuse;
    float4 Specular;
    float3 Position;
    float Range;
    float 3 Att;
    float pad;
};
struct SpotLight
{
    float4 Ambient;
    float4 Diffuse;
    float4 Specular;

    float3 Position;
    float Range;
    float3 Direction;
    float Spot;
    float3 Att;
    float pad;
};

7.12.2 結構包裝

上一節定義了HLSL結構,我們在常量緩衝區中實例化這樣的結構:

cbuffer cbPerFrame
{
    DirectionalLight gDirLight;
    PointLight gPointLight;
    SpotLight gSpotLight;
    float3 gEyePosW;
};

在應用程序級別,我們實例化鏡像結構。我們希望在一次調用中將輕實例設置爲效果變量,而不是單獨設置每個數據成員。一個結構實例可以被設置爲一個效果變量實例以下功能:

ID3DX11EffectVariable::SetRawValue(void *pData,UINT Offset, UINT Count);
// Example call:
DirectionalLight mDirLight;
mfxDirLight->SetRawValue(&mDirLight, 0, sizeof(mDirLight));

但是,因爲這個函數只是複製原始字節,所以如果我們不小心的話會導致很難發現錯誤。具體而言,我們需要注意的是C ++不遵循與HLSL相同的包裝規則。

在HLSL中,發生結構填充以使得元素被壓縮成4D向量,限制了單個元素不能跨越兩個4D向量分裂。考慮下面的例子:

//HLSL
struct S
{
    float3 Pos;
    float3 Dir;
};

如果我們必須將數據打包到4D向量中,您可能會認爲它是這樣完成的:

vector 1: (Pos.x, Pos.y, Pos.z, Dir.x)
vector 2: (Dir.y, Dir.z, empty,empty)

然而,這將元素方向分割成兩個4D向量,這是HLSL規則所不允許的 - 元素不允許跨越4D向量邊界。因此,它必須像這樣打包:

vector 1: (Pos.x, Pos.y, Pos.z,empty)
vector 2: (Dir.x, Dir.y, Dir.z,empty)

現在假設我們的鏡像C ++結構是這樣定義的:

// C++
struct S
{
    XMFLOAT3 Pos;
    XMFLOAT3 Dir;
};

如果我們不注意這些包裝規則,只是盲目地調用和複製字節,我們會得到的第一種情況是:

vector 1: (Pos.x, Pos.y, Pos.z, Dir.x)
vector 2: (Dir.y, Dir.z, empty, empty)

因此,我們必須定義我們的C ++結構,以便元素根據HLSL包裝規則正確地複製到HLSL結構中; 我們使用“pad”變量。讓我們再看幾個HLSL如何打包的例子。

struct S
{
    float3 v;
    float s;
    float2 p;
    float3 q;
};

該結構將被填充,數據將被打包成三個4D向量,如下所示:

vector 1: (v.x, v.y, v.z, s)
vector 2: (p.x, p.y, empty, empty)
vector 3: (q.x, q.y, q.z, empty)

這裏我們可以把標量s放在第一個向量的第四個分量中。然而,我們不能在矢量2的剩餘槽中擬合所有的q,所以q必須得到它自己的矢量。
最後結構體如下:

struct S
{
    float2 u;
    float2 v;
    float a0;
    float a1;
    float a2;
};

將被填充和打包,如下所示:

vector 1: (u.x, u.y, v.x, v.y)
vector 2: (a0, a1, a2, empty)

數組的處理方式不同。從SDK文檔中,“數組中的每個元素都存儲在一個fourcomponent向量中”。例如,如果您有一個float2的數組:float2 TexOffsets[8];你可能會認爲兩個float2元素將被打包到一個float4插槽中,如上面的例子所示。但是,數組是個例外,前者相當於:float4 TexOffsets[8];因此,從C ++代碼中,您需要設置一個由8個XMFLOAT4組成的數組,而不是由8個XMFLOAT2組成的數組才能正常工作。 每個元素浪費兩個存儲浮點數,因爲我們真的只想要一個float2數組。 SDK文檔指出,您可以使用強制轉換和附加地址計算指令來提高內存使用效率:
float4 array[4];
static float2 aggressivePackArray[8] = (float2[8])array;

7.12.3 定向光源的實現

下面的HLSL函數輸出給定材質,定向光源,表面法線的點的顏色,以及從表面點亮到眼睛的單位矢量:

// Defined in LightHelper.fx.
// Equation 7.3
void ComputeDirectionalLight(Material mat, DirectionalLight L,
float3 normal, float3 toEye,
out float4 ambient,
out float4 diffuse,
out float4 spec)
{
// Initialize outputs.
ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
spec = float4(0.0f, 0.0f, 0.0f, 0.0f);
// The light vector aims opposite the direction the light rays travel.
float3 lightVec = -L.Direction;
// Add ambient term.
ambient = mat.Ambient * L.Ambient;
// Add diffuse and specular term, provided the surface is in
// the line of site of the light.
float diffuseFactor = dot(lightVec, normal);
// Flatten to avoid dynamic branching.
[flatten]
if(diffuseFactor > 0.0f)
{
float3 v = reflect(-lightVec, normal);
float specFactor = pow(max(dot(v, toEye), 0.0f), mat.Specular.w);
diffuse = diffuseFactor * mat.Diffuse * L.Diffuse;
spec = specFactor * mat.Specular * L.Specular;
}
}

使用了以下內在HLSL函數:點,反射,功率和最大值,它們分別是矢量點乘積函數,矢量反射函數,冪函數和最大值函數。大部分HLSL內部函數的描述可以在附錄B中找到,以及其他HLSL語法的快速入門。有一點需要注意的是,當兩個向量與運算符*相乘時,乘法是以分量方式完成的。

Note:在PC上HLSL功能總是內聯; 因此,函數或參數傳遞沒有性能開銷。

7.12.4 點光源的實現

下面的HLSL函數輸出給定材料點,點光源,表面位置,表面法線以及從表面點亮到眼睛的單位矢量的點亮顏色:

// Defined in LightHelper.fx.
// Equation 7.4
void ComputePointLight(Material mat, PointLight L, float3 pos,
float3 normal, float3 toEye,
out float4 ambient, out float4 diffuse, out float4 spec)
{
// Initialize outputs.
ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
spec = float4(0.0f, 0.0f, 0.0f, 0.0f);
// The vector from the surface to the light.
float3 lightVec = L.Position - pos;
// The distance from surface to light.
float d = length(lightVec);
// Range test.
if(d > L.Range)
return;
// Normalize the light vector.
lightVec /= d;
// Ambient term.
ambient = mat.Ambient * L.Ambient;
// Add diffuse and specular term, provided the surface is in
// the line of site of the
light.
float diffuseFactor = dot(lightVec, normal);
// Flatten to avoid dynamic branching.
[flatten]
if(diffuseFactor > 0.0f)
{
float3 v = reflect(-lightVec, normal);
float specFactor = pow(max(dot(v, toEye), 0.0f), mat.Specular.w);
diffuse = diffuseFactor * mat.Diffuse * L.Diffuse;
spec = specFactor * mat.Specular * L.Specular;
}
// Attenuate
float att = 1.0f / dot(L.Att, float3(1.0f, d, d*d));
diffuse *= att;
spec *= att;
}

7.12.5 聚光燈的實現

以下HLSL函數輸出給定材質,聚光源,表面位置,表面法線以及從表面點亮到眼睛的單位矢量的點亮顏色:

// Defined in LightHelper.fx.
// Equation 7.5
void ComputeSpotLight(Material mat, SpotLight L,
float3 pos, float3 normal, float3 toEye,
out float4 ambient, out float4 diffuse, out float4 spec)
{
// Initialize outputs.
ambient = float4(0.0f, 0.0f, 0.0f, 0.0f);
diffuse = float4(0.0f, 0.0f, 0.0f, 0.0f);
spec = float4(0.0f, 0.0f, 0.0f, 0.0f);
// The vector from the surface to the light.
float3 lightVec = L.Position - pos;
// The distance from surface to light.
float d = length(lightVec);
// Range test.
if( d > L.Range )
return;
// Normalize the light vector.
lightVec /= d;
// Ambient term.
ambient = mat.Ambient * L.Ambient;
// Add diffuse and specular term, provided the surface is in
// the line of site of the light.
float diffuseFactor = dot(lightVec, normal);
// Flatten to avoid dynamic branching.
[flatten]
if(diffuseFactor > 0.0f)
{
float3 v = reflect(-lightVec, normal);
float specFactor = pow(max(dot(v, toEye), 0.0f), mat.Specular.w);
diffuse = diffuseFactor * mat.Diffuse * L.Diffuse;
spec = specFactor * mat.Specular * L.Specular;
}
// Scale by spotlight factor and attenuate.
float spot = pow(max(dot(-lightVec, L.Direction), 0.0f), L.Spot);
// Scale by spotlight factor and attenuate.
float att = spot / dot(L.Att, float3(1.0f, d,
d*d));
ambient *= spot;
diffuse *= att;
spec *= att;
}

7.13 例子

在我們的第一個照明演示中,我們將有三個燈同時激活:定向,點光源和聚光燈。定向燈保持固定,點光源圍繞地形轉動,聚光燈隨照相機移動,瞄準照相機正在尋找的方向。照明演示構建了上一章中的“Waves”演示。效果文件在下面的章節中給出,它使用了§7.10中定義的結構和函數。

7.13.1 Effect 文件

#include "LightHelper.fx"
cbuffer cbPerFrame
{
DirectionalLight gDirLight;
PointLight gPointLight;
SpotLight gSpotLight;
float3 gEyePosW;
};
cbuffer cbPerObject
{
float4x4 gWorld;
float4x4 gWorldInvTranspose;
float4x4 gWorldViewProj;
Material gMaterial;
};
struct VertexIn
{
float3 PosL : POSITION;
float3 NormalL : NORMAL;
};
struct VertexOut
{
float4 PosH : SV_POSITION;
float3 PosW : POSITION;
float3 NormalW : NORMAL;
};
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);
return vout;
}
float4 PS(VertexOut pin) : SV_Target
{
// Interpolating normal can unnormalize it, so normalize it.
pin.NormalW = normalize(pin.NormalW);
float3 toEyeW = normalize(gEyePosW - pin.PosW);
// 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.
float4 A, D, S;
ComputeDirectionalLight(gMaterial, gDirLight,
pin.NormalW, toEyeW, A, D, S);
ambient += A;
diffuse += D;
spec += S;
ComputePointLight(gMaterial, gPointLight,
pin.PosW, pin.NormalW, toEyeW, A, D, S);
ambient += A;
diffuse += D;
spec += S;
ComputeSpotLight(gMaterial, gSpotLight,
pin.PosW, pin.NormalW, toEyeW, A, D, S);
ambient += A;
diffuse += D;
spec += S;
float4 litColor = ambient + diffuse +spec;
// Common to take alpha from diffuse material.
litColor.a = gMaterial.Diffuse.a;
return lit Color;
}
technique11 LightTech
{
pass P0
{
SetVertexShader(CompileShader(vs_5_0, VS()));
SetGeometryShader(NULL);
SetPixelShader(CompileShader(ps_5_0, PS()));
}
}

7.13.2 C++ Application Code

7-21
圖7.21 “照明”演示的屏幕截圖

照明計算需要表面法線。 我們在頂點級定義法線, 然後將這些法線插入三角形的像素中,以便我們可以對每個像素進行光照計算。 而且,我們不再指定頂點顏色。 相反,表面顏色是通過應用每個像素的照明方程來生成的。 我們的輸入佈局描述如下所示:

D3D11_INPUT_ELEMENT_DESC vertexDesc] =
{
{"POSITION", 0, DXGI_FORMAT_R32G32B32_FLOAT, 0, 0,
D3D11_INPUT_PER_VERTEX_DATA, 0},
{"COLOR", 0, DXGI_FORMAT_R32G32B32A32_FLOAT, 0, 12,
D3D11_INPUT_PER_VERTEX_DATA, 0}
};

在應用程序類中,我們定義了三個燈光和兩個材質。

DirectionalLight mDirLight;
PointLight mPointLight;
SpotLight mSpotLight;
Material mLandMat;
Material mWavesMat;

它們在構造函數中初始化:

LightingApp::LightingApp(HINSTANCE hInstance)
{
/* ...Irrelevant code omitted... */
// Directional light.
mDirLight.Ambient = XMFLOAT4(0.2f, 0.2f, 0.2f, 1.0f);
mDirLight.Diffuse = XMFLOAT4(0.5f, 0.5f, 0.5f, 1.0f);
mDirLight.Specular = XMFLOAT4(0.5f, 0.5f, 0.5f, 1.0f);
mDirLight.Direction = XMFLOAT3(0.57735f, -0.57735f, 0.57735f);
// Point light--position is changed every frame to animate
// in UpdateScene function.
mPointLight.Ambient = XMFLOAT4(0.3f, 0.3f, 0.3f, 1.0f);
mPointLight.Diffuse = XMFLOAT4(0.7f, 0.7f, 0.7f, 1.0f);
mPointLight.Specular = XMFLOAT4(0.7f, 0.7f, 0.7f, 1.0f);
mPointLight.Att = XMFLOAT3(0.0f, 0.1f, 0.0f);
mPointLight.Range = 25.0f;
// Spot light--position and direction changed every frame to
// animate in UpdateScene function.
mSpotLight.Ambient = XMFLOAT4(0.0f, 0.0f, 0.0f, 1.0f);
mSpotLight.Diffuse = XMFLOAT4(1.0f, 1.0f, 0.0f, 1.0f);
mSpotLight.Specular = XMFLOAT4(1.0f, 1.0f, 1.0f, 1.0f);
mSpotLight.Att = XMFLOAT3(1.0f, 0.0f, 0.0f);
mSpotLight.Spot = 96.0f;
mSpotLight.Range = 10000.0f;
mLandMat.Ambient = XMFLOAT4(0.48f, 0.77f, 0.46f, 1.0f);
mLandMat.Diffuse = XMFLOAT4(0.48f, 0.77f, 0.46f, 1.0f);
mLandMat.Specular = XMFLOAT4(0.2f, 0.2f, 0.2f, 16.0f);
mWavesMat.Ambient = XMFLOAT4(0.137f, 0.42f, 0.556f, 1.0f);
mWavesMat.Diffuse = XMFLOAT4(0.137f, 0.42f, 0.556f, 1.0f);
mWavesMat.Specular = XMFLOAT4(0.8f, 0.8f, 0.8f, 96.0f);
}

使用多個燈光時,必須注意不要讓燈光過度飽和。 因此,您需要嘗試使用環境,漫反射和鏡面反射來獲得正確的平衡。 實驗的衰減和範圍也是必要的。觀察聚光燈發出黃色漫射光。 有時很容易忘記使用彩色燈,但它們可以用於各種效果。 例如,如果您使用定向光源來模擬陽光,並且太陽正在設置明亮的橙色,則可以調整發光顏色以發出橙色光,以使場景對象具有橙色色調。 當探索一艘外星飛船時,微妙的藍色燈光可以工作,紅燈可以傳達緊急情況。
如前所述,點光源和聚光燈是動畫的; 這是在pdateScene方法中完成的:

void LightingApp::UpdateScene(float dt)
{
/* ...Irrelevant code omitted... */
// Convert Spherical to Cartesian coordinates.
float x = mRadius*sinf(mPhi)*cosf(mTheta);
float z = mRadius*sinf(mPhi)*sinf(mTheta);
float y = mRadius*cosf(mPhi);
mEyePosW = XMFLOAT3(x, y, z);
// Build the view matrix.
XMVECTOR pos = XMVectorSet(x, y, z, 1.0f);
XMVECTOR target = XMVectorZero();
XMVECTOR up = XMVectorSet(0.0f, 1.0f, 0.0f, 0.0f);
/* ...Irrelevant code omitted ... */
// Circle light over the land surface.
mPointLight.Position.x = 70.0f*cosf(0.2f*mTimer.TotalTime());
mPointLight.Position.z = 70.0f*sinf(0.2f*mTimer.TotalTime());
mPointLight.Position.y = MathHelper::Max(
GetHillHeight(mPointLight.Positi
on.x, mPointLight.Position.z), -3.0f) + 10.0f;
// The spotlight takes on the camera position and is aimed in the
// same direction the camera is looking. In this way, it looks
// like we are holding a flashlight.
mSpotLight.Position = mEyePosW;
XMStoreFloat3(&mSpotLight.Direction,
XMVector3Normalize(target - pos));
}

點光源基本上沿着xz平面的圓形軌跡,但總是在地面或水面上行駛。 聚光燈定位在眼睛上,瞄準與眼睛相同的方向; 這使得它看起來像觀衆握着像手電筒一樣的光。
最後,燈光和材質在渲染之前設置爲效果:

void LightingApp::DrawScene()
{
/* ...Irrelevant code omitted... */
// Set per frame constants.
mfxDirLight->SetRawValue(&mDirLight, 0, sizeof(mDirLight));
mfxPointLight->SetRawValue(&mPointLight, 0, sizeof(mPointLight));
mfxSpotLight->SetRawValue(&mSpotLight, 0, sizeof(mSpotLight));
mfxEyePosW->SetRawValue(&mEyePosW, 0, sizeof(mEyePosW));
/* ...Irrelevant code omitted... */
// Set land material (material varies per object).
mfxMaterial->SetRawValue(&mLandMat, 0, sizeof(mLandMat));
/* ...Render land... */
// Set wave material (material varies per object).
mfxMaterial->SetRawValue(&mWavesMat, 0, sizeof(mWaves
Mat));
/* ...Render waves... */
}

7.13.3向量計算

因爲我們的地形曲面由函數y = f(x,z)給出,我們可以直接使用微積分來計算法向量,而不是使用第7.2.1節中描述的正常平均技術。爲此,對於曲面上的每個點,我們通過取偏導數在+ x和+ z方向上形成兩個切向量:

Tx=1,fx,0Tz=0,fz,1

這兩個向量位於曲面點的切平面上。 交叉乘積然後給出法向量:
n=Tz×Tx=ijk0fz11fx0=,fx,0
發佈了7 篇原創文章 · 獲贊 7 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章