【3D數學】——透視變換與相機偏手性

書上和網上有很多透視變換的例子,但是其缺點是只討論一種情況,無法將不同的情況關聯(lián)起來,因此整理一篇文章,希望可以把透視變換的相關東西都聯(lián)系起來。

透視變換

透視變換,就是用一個矩陣(透視變換矩陣)將整個場景轉換到齊次裁剪空間之中。所謂的齊次裁剪空間,是一個特殊的空間,它的作用就是讓渲染管線可以對這個空間中一定范圍內的物體保留,超出范圍的物體剔除,如果有截斷表面的情況,還要生成新的頂點數據。
齊次裁剪空間通常有兩種,兩種齊次裁剪空間的x和y坐標范圍都是[-1,1],但是z坐標的范圍不同,一種是[0,1],一種是[-1,1]。很多書上,網上的教程都會說DX的齊次裁剪空間是[0,1]的那種,而OpenGL的裁剪空間是[-1,1]的那種。不過到底事實是什么樣的,我們還得試過之后才知道。
為了在計算機中模擬人眼看東西的狀態(tài),我們想出了這樣的一個模型:把人眼固定在相機空間的原點,然后從原點定義一個四棱錐,表示人眼可以看到的范圍,就像下面這張圖一樣:



這個四棱錐就被稱為視景體。除此之外,對于離眼睛太近的物體我們看不到,太遠的物體同樣也看不到,所以我們可以選取一定范圍內的物體顯示出來,這樣可以節(jié)省渲染時間。那么多近的物體不顯示,多遠的物體不顯示呢?這個沒有定論,經常使用n和f表示這兩個距離,比n近的物體不顯示,比f還遠的物體不顯示。這兩個面就把視景體截斷成一個四棱臺,這個四棱臺里的物體會被繪制,而在外面的不會。

接著,我們就可以來推導這個透視變換矩陣了。此時我們是在相機中間中,并且假設相機空間是一個右手坐標系。

水平視場角

下圖是從視景體的上方垂直看下去的圖,就像是三視圖一樣。圖中,三角形的頂邊就是投影平面。



參數說明:為了計算方便,我們取特殊的投影平面,它的x坐標區(qū)間是[-1,1],這個坐標區(qū)間剛好可以直接當成齊次裁剪空間的坐標。如圖中所示,投影平面距離原點的距離為e,注意這是距離,不是投影平面的z坐標,這個e有時也被稱為相機的焦距。這樣,左錐面和右錐面形成的夾角為xfov,稱為水平視場角。

水平視場角,英文名是horizontal angle of view,但是通常,我們會把視場角和視野混用,視野的英文是field of view,所以我就用xfov(x-axis field of view)來表示水平視場角。

可以看到,距離e滿足e = \frac{1}{\tan(\frac{xfov}{2})}
再來看看垂直方向上:

垂直方向與水平方向類似,投影平面的縱橫比(寬除以高)用aspect表示,這個比值通常是16:9,就是我們顯示器的寬高比。所以,投影平面的y坐標范圍為[-1/aspect, 1/aspect]。如圖中所示,垂直視場角yfov = 2\arctan(\frac{\frac{1}{aspect}}{e})

計算投影后的x坐標


原始的頂點是v,投影之后的頂點是v',根據相似三角形原理,我們可以列出下面的等式:


求解x'得:


接著計算y'的值。



如上圖所示,同樣使用相似三角形的公式來計算:



于是

到這里,y'的計算還沒有結束,最關鍵的一步來了!上述的推導成立的前提是,y的投影面是[-1/aspect, 1/aspect],而齊次裁剪空間中,y的取值范圍必須是[-1,1],所以,最后一步是,將上述計算出來的y'值除以1/aspect,得出的結果才是齊次裁剪空間中真正的y值:


轉換之前的坐標記為(x, y, z, 1),轉換之后的坐標就是(x', y', z', 1),將之間計算的結果代入得到轉換之后的坐標是


在齊次坐標系統(tǒng)中,點(x, y, z, 1)和點(cx, cy, cz, c)表示的是同一個點,如果這個c不是0的話。利用這個性質,我們可以對上述坐標乘以一個z得到如下的形式


列出轉換矩陣表示這個轉換過程

轉換矩陣中有四個參數沒有確定,我們需要把這四個參數解出來。先來思考一下,對z進行投影,這和x,y坐標有沒有關系?顯然沒關系,所以m1和m2的值肯定是0。接下來考慮m3和m4,我們可以列出下面的方程

兩邊除以z得到

接著,我們說過齊次裁剪空間的z坐標范圍有兩種,一個是[0,1],一個是[-1,1],我們先計算[0,1]。這種轉換,近裁剪面(n)轉換到0,遠裁剪面(f)轉換到1,于是我們將坐標代入(注意我們所說的裁剪面n和f是距離)得到方程組

解方程組得到的結果是

將結果代入到轉換矩陣里,我們就能得到最終的轉換矩陣:

投影矩陣有很多變種,嗯,多到頭暈的變種。比如,之前我們?yōu)榱朔奖阌嬎悖瑢D換后的坐標值乘以了一個z得到的

顯然,我可以不乘z,如果我乘以一個-z得到的結果會是什么呢?

用這個結果去推導投影矩陣,得到的結果是:

很明顯,P2 = -P1。這兩個矩陣效果都一樣嗎?事實是,不一樣!P1是一個無效的透視矩陣,P2才是有效的矩陣。這里涉及到裁剪的相關原理。

透視矩陣會把所有的頂點都轉換到齊次裁剪空間中,為啥叫齊次裁剪空間?因為在這個空間中,會發(fā)生裁剪的操作。為啥是齊次?因為這個空間中的點,w坐標不是1,而是z或者-z。裁剪空間要做的第一件事,就是把w坐標小于0的頂點都剔除。而我們的z坐標,是一個小于0的值!所以,如果你用P1作為透視矩陣,那么你無法看到任何東西,因為它們都被剔除掉了。不信?試試~

推導這么復雜,寫成代碼就很簡單了,只要把結果賦值就好了。

float cFov = 1.f / tanf(xfov / 2);
result.m[0][0] = cFov;
result.m[1][1] = aspect * cFov;
result.m[2][3] = -1;
result.m[2][2] = far / (near - far);
result.m[3][2] = near * far / (near - far);

代碼的矩陣是P2矩陣的轉置,因為這種數據格式傳遞給渲染管線的時候計算方便。運行測試場景,得到的結果是:


另一個齊次裁剪空間

之前我們首先的齊次裁剪空間的z范圍是[0,1],那么如果它的范圍是[-1,1],又會是什么結果呢?下面我們來嘗試推導[-1,1]的投影矩陣,這個不難,只有最后轉換z坐標的時候有區(qū)別,我們以乘以-z為基礎來推導。
\begin{bmatrix}\cot({\frac{xfov}{2}}) && 0 && 0 && 0\\ 0 && aspect·(\cot{\frac{xfov}{2}}) && 0 && 0\\ m_1 && m_2 && m_3 && m_4\\ 0 && 0 && -1 && 0\end{bmatrix}·\begin{bmatrix}x \\ y \\ z \\ 1\end{bmatrix}=\begin{bmatrix} x·(\cot{\frac{xfov}{2}})\\ y·aspect·(\cot{\frac{xfov}{2}})\\ -z·z'\\ -z \end{bmatrix}
首先,m1和m2還是0,因為x坐標和y坐標對z投影之后的坐標值沒有影響。然后,列出z坐標轉換公式:
m_3·z + m_4 = -z·z'
兩邊除以-z
-m_3 - \frac{m_4}{z} = z'
現在,我們要把近裁剪面轉換成-1,遠裁剪面轉換成1,列出方程:
\begin{cases} -m_3 + \frac{m_4}{n} = -1\\ -m_3 + \frac{m_4}{f} = 1 \end{cases}
求得
\begin{cases} m_3 = \frac{n + f}{n - f}\\ m_4 = \frac{2nf}{n - f} \end{cases}
最后的轉換矩陣是:
\begin{bmatrix}\cot({\frac{xfov}{2}}) && 0 && 0 && 0\\ 0 && aspect·(\cot{\frac{xfov}{2}}) && 0 && 0\\ 0 && 0 && \frac{n + f}{n - f} && \frac{2nf}{n - f}\\ 0 && 0 && -1 && 0\end{bmatrix}
再用代碼實現:

float cFov = 1.f / tanf(xfov / 2);
result.m[0][0] = cFov;
result.m[1][1] = aspect * cFov;
result.m[2][3] = -1;
result.m[2][2] = (near + far) / (near - far);
result.m[3][2] = (2 * near * far) / (near - far);

應用這個投影矩陣,得到的結果是:



沒有半毛錢區(qū)別!

上面兩張圖是OpenGL下運行的結果,下面兩張圖是D3D12下運行的結果:


D3D12 z區(qū)間為[0,1]

D3D12 z區(qū)間為[-1,1]

可以看到,上面兩張圖也沒啥區(qū)別。而對比OpenGL和D3D12的圖會發(fā)現,OpenGL中顯示的物體稍微偏上了一點,為什么會產生這種效果,筆者也不清楚。

如果不用fov,還能用什么參數計算投影矩陣?

在《3D游戲與計算機圖形學中的數學方法》一書和《Real Time Rendering 4th Edition》一書中,提供了一種不用fov的方法。這個方法是這樣的:
首先確定投影平面的位置,令投影平面正好位于近裁剪面處,它與觀察點之間的距離為n,所以其z坐標就是-n。然后,這個投影平面的x范圍是[l, r],y范圍是[b, t]。先通過相似原理列出一個點(x, y, z, 1)投影到上述投影平面上之后的x,y坐標,令投影后的坐標為(x', y', z', 1):
\frac{x'}{x} = \frac{z'}{z}=\frac{-n}{z}
x' = (-n)\frac{x}{z}
同理
y'=(-n)\frac{y}{z}
現在,我們來計算假如投影平面上有一點(x', y'),要將其坐標映射到[-1,1]區(qū)間,計算公式是什么樣的?
因為投影平面上x坐標取值范圍是[l, r],我們可以通過x'到l的距離占(r - l)的比例來映射:
\frac{x - l}{r - l} \quad \text{x到l的距離占總長度之比,它的范圍是[0,1]}
要將這個范圍轉換到[-1,1],只需要將這個范圍翻倍,然后左移一個單位的距離就可以,即:
x'' = \frac{x' - l}{r- l}·2 - 1
同理
y'' = \frac{y' - b}{t- b}·2 - 1
將x'和y'的式子代入到x''和y''的公式中
x'' = \frac{(-n)\frac{x}{z} - l}{r- l}·2 - 1=\frac{(-2n)\frac{x}{z} - 2l}{r- l} - 1=\frac{-2n}{r-l}·\frac{x}{z}-\frac{2l}{r-l}-1=\frac{2n}{r-l}(-\frac{x}{z})-\frac{r+l}{r-l}
y'' = \frac{2n}{t-b}(-\frac{y}{z})-\frac{t+b}{t-b}
轉換后的點為
\begin{bmatrix} \frac{2n}{r-l}(-\frac{x}{z})-\frac{r+l}{r-l} \\ \frac{2n}{t-b}(-\frac{y}{z})-\frac{t+b}{t-b} \\ z''\\ 1 \end{bmatrix}
老規(guī)矩,乘以-z
\begin{bmatrix} \frac{2n}{r-l}(x)+\frac{r+l}{r-l}z \\ \frac{2n}{t-b}(y)+\frac{t+b}{t-b}z \\ (-z)·z''\\ -z \end{bmatrix}
列出矩陣
\begin{bmatrix}\frac{2n}{r-l} && 0 && \frac{r+l}{r-l} && 0\\ 0 && \frac{2n}{t-b} && \frac{t+b}{t-b} && 0\\ 0 && 0 && m_3 && m_4\\ 0 && 0 && -1 && 0\end{bmatrix}·\begin{bmatrix}x \\ y \\ z \\ 1\end{bmatrix}=\begin{bmatrix} \frac{2n}{r-l}(x)+\frac{r+l}{r-l}z \\ \frac{2n}{t-b}(y)+\frac{t+b}{t-b}z \\ (-z)·z''\\ -z \end{bmatrix}
m3和m4的計算和上面一樣,我們直接寫出結果就行。對[0,1]區(qū)間是
\begin{bmatrix}\frac{2n}{r-l} && 0 && \frac{r+l}{r-l} && 0\\ 0 && \frac{2n}{t-b} && \frac{t+b}{t-b} && 0\\ 0 && 0 && \frac{f}{n-f} && \frac{nf}{n - f}\\ 0 && 0 && -1 && 0\end{bmatrix}
對[-1,1]區(qū)間是
\begin{bmatrix}\frac{2n}{r-l} && 0 && \frac{r+l}{r-l} && 0\\ 0 && \frac{2n}{t-b} && \frac{t+b}{t-b} && 0\\ 0 && 0 && \frac{n + f}{n - f} && \frac{2nf}{n - f}\\ 0 && 0 && -1 && 0\end{bmatrix}
接著我們用代碼來實現這個投影矩陣:

// 注意矩陣要設置成我們推導結果的轉置。
void BuildPerspectiveFovRHMatrix(Matrix4f& result, const float l, const float r, float b, float t, const float n, const float f)
{
    result.Set(0);

    result.m[0][0] = 2 * n / (r - l);
    result.m[1][1] = 2 * n / (t - b);
    result.m[2][0] = (r + l) / (r - l);
    result.m[2][1] = (t + b) / (t - b);
    if (g_DepthClipSpace == DepthClipSpace::kDepthClipZeroToOne)
    {
        result.m[2][2] = f / (n - f);
        result.m[3][2] = n * f / (n - f);
    }
    else /* g_DepthClipSpace == DepthClipSpace::kDepthClipNegativeOneToOne */
    {
        result.m[2][2] = (n + f) / (n - f);
        result.m[3][2] = (2 * n * f) / (n - f);
    }

    return;
}
(fov)式投影矩陣和(l,r,b,t)式投影矩陣的聯(lián)系

這兩個的關系非常明確,把我們推導fov矩陣時假設的參數代入就行了。l等于-1,r等于1,b等于-1/aspect,t等于1/aspect。代入之后,(l,r,b,t)式矩陣就變成
\begin{bmatrix}n && 0 && 0 && 0\\ 0 && n·aspect && 0 && 0\\ 0 && 0 && \frac{f}{n-f} && \frac{nf}{n - f}\\ 0 && 0 && -1 && 0\end{bmatrix}
而這里的n是投影平面到原點的距離,也就是說
n=\cot(\frac{xfov}{2})
于是(l,r,b,t)式矩陣就和(fov)式矩陣畫上等號了。如果n = 0.1,那么這個(l,r,b,t)式矩陣的xfov值就是168.58度。來看看運行效果:

OpenGL z范圍[0, 1]

OpenGL z范圍[-1, 1]

D3D12 z范圍[0, 1]

D3D12 z范圍[-1, 1]

OpenGL的顯示還是比D3D12偏上一些,整個物體也變小很多,因為我們這次的xfov值太大了。

到這里,我們就可以回答本節(jié)最開始的問題了,對DX和OpenGL渲染管線來說,不存在裁剪空間[0,1]還是[-1,1]的區(qū)別,而之所以印象中有這區(qū)別,是因為在寫DX和OpenGL代碼時,所用的數學庫一個是[0,1],一個是[-1,1]。

如果相機空間是左手坐標系,那怎么辦?

從最初的推理開始,左手坐標系改變了什么?投影平面的z坐標改變了,變成了\cot\frac{xfov}{2}隨之而來的x和y坐標的表達式也變了x'=\frac{x}{z}(\cot{\frac{xfov}{2}})
y'=\frac{y}{z}(\cot{\frac{xfov}{2}})·aspect
緊接著,我們不用再乘以-z,列出的矩陣也變了
\begin{bmatrix}\cot({\frac{xfov}{2}}) && 0 && 0 && 0\\ 0 && aspect·(\cot{\frac{xfov}{2}}) && 0 && 0\\ 0 && 0 && m_3 && m_4\\ 0 && 0 && 1 && 0\end{bmatrix}·\begin{bmatrix}x \\ y \\ z \\ 1\end{bmatrix}=\begin{bmatrix} x·(\cot{\frac{xfov}{2}})\\ y·aspect·(\cot{\frac{xfov}{2}})\\ z·z'\\ z \end{bmatrix}
求解m3和m4的方程也變了
m_3 + \frac{m_4}{z} = z'
n和f坐標地改變,導致代入方程的數據也變了,先計算z范圍是[0,1]的轉換矩陣
\begin{cases} m_3 + \frac{m_4}{n} = 0\\ m_3 + \frac{m_4}{f} = 1 \end{cases}
求得的結果是
\begin{cases} m_3 = -\frac{f}{n - f}\\ m_4 = \frac{nf}{n - f} \end{cases}
最終轉換矩陣為
\begin{bmatrix}\cot({\frac{xfov}{2}}) && 0 && 0 && 0\\ 0 && aspect·(\cot{\frac{xfov}{2}}) && 0 && 0\\ 0 && 0 && -\frac{f}{n - f} && \frac{nf}{n - f}\\ 0 && 0 && 1 && 0\end{bmatrix}
計算z范圍是[-1,1]轉換矩陣的方程是
\begin{cases} m_3 + \frac{m_4}{n} = -1\\ m_3 + \frac{m_4}{f} = 1 \end{cases}
解的m3和m4的值為
\begin{cases} m_3 = -\frac{n+f}{n - f}\\ m_4 = \frac{2nf}{n - f} \end{cases}
最終的投影矩陣是
\begin{bmatrix}\cot({\frac{xfov}{2}}) && 0 && 0 && 0\\ 0 && aspect·(\cot{\frac{xfov}{2}}) && 0 && 0\\ 0 && 0 && -\frac{n+f}{n - f} && \frac{2nf}{n - f}\\ 0 && 0 && 1 && 0\end{bmatrix}
編寫代碼實現這個投影矩陣

// 還是要注意我們要按照轉置的思路去設置
void BuildPerspectiveFovLHMatrix(Matrix4f& result, const float xfov, const float aspect, const float near, const float far)
{
    result.Set(0);

    float cFov = 1.f / tanf(xfov / 2.f);
    result.m[0][0] = cFov;
    result.m[1][1] = aspect * cFov;
    result.m[2][3] = 1.0f;

    if (g_DepthClipSpace == DepthClipSpace::kDepthClipZeroToOne)
    {
        result.m[2][2] = -far / (near - far);
        result.m[3][2] = near * far / (near - far);
    }
    else
    {
        result.m[2][2] = -(far + near) / (near - far);
        result.m[3][2] = (2 * near * far) / (near - far);
    }

    return;
}

修改相應的代碼查看效果


lefthand_OpenGL_zero_to_one

lefthand_OpenGL_negative_one_to_one

lefthand_D3D12_zero_to_one

lefthand_D3D12_negative_one_to_one

不用看盒子的顯示狀況,只需看顯示的位置就可以了。因為這個場景是從blender導出的,而blender是右手坐標系的,所以它給出的頂點法線都是以右手坐標系給出的,我在這里做了一些小手腳,把攝像機放在了原點,在它背面放了一個盒子。用了一個左手坐標系的投影矩陣后,后面的盒子就顯示出來了。可以看到,位置和右手坐標系及對應的OpenGL和D3D12圖片是完全一致的,說明我們的推導沒有問題。

那么,不用xfov的投影矩陣怎么辦呢?

嗯,懶了,留給讀者推導吧。

補充說明

本文用于測試的程序是我在github上的Panda項目。項目的使用方法是:
(1)首先你得有一個環(huán)境,Win10+VS2017+CMake+Git+Git-Lfs+github賬號
(2)克隆項目到本地(克隆過程請耐心等待,原本github就不快,而且我項目里還有大文件(git-lfs更加慢得令人發(fā)指),100M以上的那種),逐個運行build_assimp.bat,build_crossguid.bat,build_libpng.bat,build_opengex.bat,build_zlib.bat
(3)運行build.bat
(4)打開build文件夾下,Panda.sln解決方案。
(5)運行EditorD3D或是EditorOGL。
投影矩陣的生成函數在Numerical.cpp文件中,使用的地方在GraphicsManager.cpp的CalculateCameraMatrix()函數中。模型加載的地方是EditorLogic.cpp的Initialize()函數。上面用到的模型文件是article.dae和article_lefthand.dae,模型文件在根目錄/Asset/Scene文件夾中,你可以隨意嘗試加載文件看效果。如果有什么困難,請在下方留言。

參考資料

《3D游戲與計算機圖形學中的數學方法》
《Real-Time Render 4th Edition》
《3D Graphics for Game Programming》

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,885評論 6 541
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發(fā)現死者居然都...
    沈念sama閱讀 99,312評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,993評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,667評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,410評論 6 411
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 55,778評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,775評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,955評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發(fā)現了一具尸體,經...
    沈念sama閱讀 49,521評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,266評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,468評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,998評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點故事閱讀 44,696評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,095評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,385評論 1 294
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,193評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,431評論 2 378

推薦閱讀更多精彩內容