WebGL基礎教程:第三部分

歡迎回到第三部分,也是咱們的迷你WebGL教程系列的最後一部分。在此課程中,咱們會會介紹光照和添加2D對象到場景中。新的內容不少,咱們仍是直接開始吧。前端

光照

光照多是3D應用中最技術化和最難理解的部分了。牢固地掌握光照知識絕對是很是基本的。程序員

光照是如何工做的?

在咱們介紹不一樣類型的光照,和編碼技術以前,一件重要的事情是,理解真實世界中的光照是如何造成的。 每一個光源 (好比:一個燈泡,太陽,等等) 產生了稱爲光子的粒子。這些光子在對象周圍彈跳,直到它們最終進入咱們的眼睛。 咱們的眼睛將光子轉化爲一個可視的"圖像"。這就是咱們可以看到東西的原理。光是可加的,意思是一個顏色更多的對象要比沒有顏色 (黑色) 的對象顯得更亮一些。 黑色是絕對的沒有顏色,而白色包含了全部的顏色。這是在處理很是亮或"過飽和"光照時須要注意的一個重要區別。web

亮度只不是是具備多個狀態的一個原則。好比,反射能夠有多個不一樣的層次。像鏡子同樣的一個對象能夠是徹底反射的,而其它對象的表面則少一些光澤。 透明度決定了對象如何彎曲和折射光線;一個對象能夠是徹底透明的,也能夠是徹底不透明,或中間的任意狀態。算法

這個知識清單還能夠繼續列下去,但我想你已經意識到光照不是那麼簡單了。

若是你想在一個小場景中對真實光照進行仿真,頗有可能一個小時只能渲染4幀,這仍是高性能電腦的狀況。 爲了克服這個問題,程序員們使用了一些技巧和技術來仿真半真實的光照,以實現更合理的幀率。 你必須在真實感和速度之間進行妥協。讓咱們看一看部分這樣的技術。canvas

在我開始詳細介紹不一樣的技術時,我要先小小地聲明一下。 對於不一樣的光照技術,它們精確名稱是有爭議的,好比"光線跟蹤"或"光照映射"技術,不一樣的人會給出不一樣的解釋來。 因此,在咱們捲入這種招人恨的爭議中以前,我要說的是,我只是用了我所學過的名稱;有些人可能並不會贊成我用的名詞。 不管如何,重要的是知道不一樣的技術具體是什麼。再也不囉嗦,咱們開始吧。數組

你必須在真實感和速度之間進行權衡。

光線跟蹤

光線跟蹤是更具真實感的一種光照技術,但它也是更耗時的一種。光線跟蹤模仿了真實光;它從光源處發射"光子"或"光線",並讓它們四處彈跳。 在大多數光線跟蹤實現中,光線來自於"攝像機",並延相反方向彈向場景。這個技術一般用於電影,或能夠提早渲染的場合。 這並非說,你不能在實時應用中使用光線跟蹤,但這樣作會迫使你調整場景中的其它東西。 好比,你可能必需要減小光線必須"彈跳"的次數,或你能夠確保沒有對象有反射或折射表面。 若是你的應用中光源和對象較少,光線跟蹤也是一個可行選項。瀏覽器

若是你有一個實時應用,你可能會提早編譯場景內的部份內容。

若是應用中的光源不會處處移動,或一次只在小區域內移動,則你能夠有一種很是高級的光線跟蹤算法來預編譯光照,並在移動光源附近從新計算一個小區域。 好比,若是你在作一個遊戲應用,其中的光源是不動的,你能夠預編譯整個遊戲世界,並實現所需的光照和效果。 而後,當你的角色移動時,你能夠只在它附近添加一個陰影。這會獲得很是高質量的效果,而只須要最小的處理量。緩存

光線投射

光線投射與光線跟蹤很是類似,只不過"光子"再也不彈跳或與不一樣材料進行交互。 在一個典型的應用中,你基本上是一個黑暗的場景開始的,而後你會從光源發射一些光線。光線所到之處會被點亮,而其它區域仍然保持黑暗。 這個技術比光線跟蹤快不少,但仍然給你一個真實的陰影效果。但光線投射的問題在於它的嚴格限制;當須要添加光線反射效果時,你並無太多辦法可想。 一般,你不得不在光線投射和光線追蹤之間進行妥協,在速度和視覺效果之間進行平衡。bash

這兩種技術的主要問題在於WebGL並不會讓你訪問到除當前頂點外的其它頂點。

這意味着你要麼在CPU (相對於圖形卡) 上處理一切,要麼用第二個着色器來計算全部光照,而後將信息存於一個假紋理上。 而後,你須要將紋理解壓縮爲光照信息,並映射到頂點上。 因此,基本上,WebGL當前的版本不是很適合於這個任務。但我並非說沒法作到,我只是說WebGL幫不了你。微信

Shadow Mapping

若是你的應用中光照和對象不多,光線追蹤是一個可行選項。

在WebGL中,光線投射的一個更好的替代品是陰影映射。它能夠獲得和光線投射同樣的效果,但用到的是一種不一樣的技術。 陰影映射不會解決你的全部問題,但WebGL對它是半優化了的。你能夠將其理解爲一種詭計,但陰影映射確實被用於真實的PC和終端應用中了。

你會問,那麼它究竟是什麼呢?

你必須理解WebGL是如何渲染場景的,而後才能回答這個問題。WebGL將全部的頂點傳入頂點着色器,在應用了變換以後,它會計算出每一個頂點的最終座標。 而後,爲了節約時間,WebGL丟掉了被擋在其它對象以後的那些頂點,且只畫最重要的對象。就像光線投射同樣,它只不過是將光線投射到可見對象上。 因此,咱們將場景的"攝像機"設置爲光源的座標,並讓它的朝向光線前進的方向。 而後,WebGL自動刪除不在光線照耀下的那些頂點。因而,咱們能夠將這個數據存起來,使得咱們在渲染場景時知道哪些頂點上是有光照的。

這個技術在紙面上聽起來不錯,可是它有一些缺點:

  • WebGL不容許你訪問深度緩存;你須要在片元着色器中採用創造性的方法來保存這個數據。
  • 即便你保存了全部的數據,在渲染場景時,你仍然須要在它們進入頂點數組以前將它們映射到頂點上。這須要額外的CPU時間。

全部這些技術須要大量的WebGL技巧。但我這裏展現的是一種很是基本的技術,它能夠產生一種散射的光照,使得你的對象更有個性。 我不會稱其爲真實感光照,但它確實讓你的對象更有意思。這個技術用到了對象的法向量矩陣,以計算相對於對象表面的光線夾角。 這是很是快而高效的,不須要什麼WebGL技巧。讓咱們開始吧。

添加光照

讓咱們先修改着色器,增長光照功能。咱們須要增長一個boolean變量,用來肯定一個對象是否應該有光照。 而後,咱們須要實際的法向量,並將變換到與模型對齊。最後,咱們要用一個變量,將最後的結果傳遞給片元着色器。下面是咱們的新的頂點着色器:

<script id="VertexShader" type="x-shader/x-vertex">  
attribute highp vec3 VertexPosition;
attribute highp vec2 TextureCoord;
attribute highp vec3 NormalVertex;
uniform highp mat4 TransformationMatrix;
uniform highp mat4 PerspectiveMatrix;
uniform highp mxat4 NormalTransformation;
uniform bool UseLights;
varying highp vec2 vTextureCoord;
varying highp vec3 vLightLevel;
void main(void) {
 gl_Position = PerspectiveMatrix * TransformationMatrix * vec4(VertexPosition, 1.0);
 vTextureCoord = TextureCoord;
 if (UseLights) {
 highp vec3 LightColor = vec3(0.15, 0.15, 0.15);
 highp vec3 LightDirection = vec3(0.5, 0.5, 4);
 highp vec4 Normal = NormalTransformation * vec4(VertexNormal, 1.0);
 highp float FinalDirection = max(dot(Normal.xyz, LightDirection), 0.0);
 vLightLevel = (FinalDirection * LightColor);
 } else {    
 vLightLevel = vec3(1.0, 1.0, 1.0);
 }
}
</script>複製代碼

若是我不用這些光照,則咱們只不過將一個空白頂點傳遞到片元着色器,從而顏色將保持不變。當光照打開時,咱們用點乘函數來計算光線方向與對象表面法向之間的夾角,而且讓結果乘以光線的顏色,做爲一種覆蓋在對象上的掩膜。

Oleg Alexandrov畫的曲面法向量。

這是可行的,由於法向量已經與對象表面垂直,而點乘函數獲得一個由光線與法向量的夾角相關的數。若是法向量和光線幾乎是平行的,則點乘函數返回一個正數,表示光線是正對着表面的。 當法向量和光線垂直時,曲面與光線平行,點乘函數返回零。光線與法向量之間的角度大於90度時會獲得負數,但咱們會用"max zero"函數將這些狀況過濾掉。

如今,讓我給出以下的片元着色器:

<script id="FragmentShader" type="x-shader/x-fragment">  
varying highp vec2 vTextureCoord;
varying highp vec3 vLightLevel;
uniform sampler2D uSampler;
void main(void) {
 highp vec4 texelColor = texture2D(uSampler, vec2(vTextureCoord.s, vTextureCoord.t));
 gl_FragColor = vec4(texelColor.rgb * vLightLevel, texelColor.a);
}     
</script>複製代碼

這個着色器與上篇文章很是相同。惟一的差異在於咱們將紋理的顏色乘上了光線層次。這個亮度或暗度將對象的不一樣部分區分開,從而表現出深度信息。

着色器就是這些了,如今咱們回到WebGL.js文件,並修改其中的兩個類。

更新咱們的框架

咱們先從GLObject類開始。咱們須要加一個變量來表示法向量數組。這裏是GLObject類的定義的最開始的一部分代碼:

function GLObject(VertexArr, TriangleArr, TextureArr, ImageSrc, NormalsArr) {
    this.Pos = { X : 0, Y : 0, Z : 0};
    this.Scale = { X : 1.0, Y : 1.0, Z : 1.0};
    this.Rotation = { X : 0, Y : 0, Z : 0};
    this.Vertices = VertexArr;
 
    //Array to hold the normals data
    this.Normals = NormalsArr;
 
    //The Rest of GLObject continues here複製代碼

這個代碼的意思很明顯。如今,咱們回到HTML文件,併爲咱們的對象添加法向量數組。

Ready()函數中,咱們已經加載了3D模型,咱們還須要增長表示法向量數組的參數。 一個空數組表示模型並不包含任何法向量數據,因而咱們不得不在沒有光照的狀況下繪製對象。當此數組包含數據時,咱們要將其傳遞給GLObject對象。

咱們還須要更新WebGL類。咱們須要在加載完着色器後馬上將變量連接到着色器。讓咱們添加法向量頂點;你的代碼如今應該像下面這樣了:

//Link Vertex Position Attribute from Shader
this.VertexPosition = this.GL.getAttribLocation(this.ShaderProgram, "VertexPosition");
this.GL.enableVertexAttribArray(this.VertexPosition);
//Link Texture Coordinate Attribute from Shader
this.VertexTexture = this.GL.getAttribLocation(this.ShaderProgram, "TextureCoord");
this.GL.enableVertexAttribArray(this.VertexTexture);
//This is the new Normals array attribute
this.VertexNormal = this.GL.getAttribLocation(this.ShaderProgram, "VertexNormal");
this.GL.enableVertexAttribArray(this.VertexNormal);複製代碼

接下來,讓咱們更新PrepareModel()函數,並增長代碼來在合適的時候緩存法向量數據。新的代碼置於底部Model.Ready語句以前:

if (false !== Model.Normals) {
 Buffer = this.GL.createBuffer();
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Buffer); 
 this.GL.bufferData(this.GL.ARRAY_BUFFER, new Float32Array(Model.Normals), this.GL.STATIC_DRAW);
 Model.Normals = Buffer;
}
Model.Ready = true;複製代碼

最後,一樣重要的一件事是,更新實際的Draw函數,來併入全部這些修改。由於有很多的修改,我打算對整個函數逐一的瀏覽一遍。

this.Draw = function(Model) {
 if(Model.Image.ReadyState == true && Model.Ready == false) {
 this.PrepareModel(Model);
 }
 if (Model.Ready) {
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Vertices); 
 this.GL.vertexAttribPointer(this.VertexPosition, 3, this.GL.FLOAT, false, 0, 0); 
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.TextureMap);
 this.GL.vertexAttribPointer(this.VertexTexture, 2, this.GL.FLOAT, false, 0, 0);複製代碼

到這裏爲止,還和之前同樣。而後是法向量部分:

//Check For Normals
if (false !== Model.Normals) {
 //Connect The normals buffer to the Shader
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Normals);
 this.GL.vertexAttribPointer(this.VertexNormal, 3, this.GL.FLOAT, false, 0, 0);
 //Tell The shader to use lighting
 var UseLights = this.GL.getUniformLocation(this.ShaderProgram, "UseLights");  
 this.GL.uniform1i(UseLights, true);
} else {
 //Even if our object has no normals data we still have to pass something
 //So I pass in the Vertices instead
 this.GL.bindBuffer(this.GL.ARRAY_BUFFER, Model.Vertices);
 this.GL.vertexAttribPointer(this.VertexNormal, 3, this.GL.FLOAT, false, 0, 0);
 //Tell The shader to use lighting
 var UseLights = this.GL.getUniformLocation(this.ShaderProgram, "UseLights");  
 this.GL.uniform1i(UseLights, false);
}複製代碼

咱們檢查模型是否有法向量數據。若是有,則將其連接到緩存,並設置boolean變量。若是沒有,則着色器仍然須要某種數據,不然會報錯。 因此,我傳遞了頂點緩存,並將UseLight變量設置爲false。你能夠用多個着色器來處理這種狀況,但我認爲個人方案在當前場合下會更簡單一些。

this.GL.bindBuffer(this.GL.ELEMENT_ARRAY_BUFFER, Model.Triangles);
//Generate The Perspective Matrix
var PerspectiveMatrix = MakePerspective(45, this.AspectRatio, 1, 1000.0);  
var TransformMatrix = Model.GetTransforms();複製代碼

本函數的這一部分仍然保持不變。

varNormalsMatrix =  MatrixTranspose(InverseMatrix(TransformMatrix));複製代碼

接下來是計算法向變換矩陣。我會立刻討論MatrixTranspose()InverseMatrix()函數。 爲了計算法向量數組的變換矩陣,咱們須要計算對象的常規變換矩陣的逆矩陣的轉置。這個主題後面會介紹。

//Set slot 0 as the active Texture
 this.GL.activeTexture(this.GL.TEXTURE0);
 //Load in the Texture To Memory
 this.GL.bindTexture(this.GL.TEXTURE_2D, Model.Image);
 //Update The Texture Sampler in the fragment shader to use slot 0
 this.GL.uniform1i(this.GL.getUniformLocation(this.ShaderProgram, "uSampler"), 0);
 //Set The Perspective and Transformation Matrices
 var pmatrix = this.GL.getUniformLocation(this.ShaderProgram, "PerspectiveMatrix");  
 this.GL.uniformMatrix4fv(pmatrix, false, new Float32Array(PerspectiveMatrix));
 var tmatrix = this.GL.getUniformLocation(this.ShaderProgram, "TransformationMatrix");  
 this.GL.uniformMatrix4fv(tmatrix, false, new Float32Array(TransformMatrix));  
 var nmatrix = this.GL.getUniformLocation(this.ShaderProgram, "NormalTransformation");  
 this.GL.uniformMatrix4fv(nmatrix, false, new Float32Array(NormalsMatrix));  
 //Draw The Triangles
 this.GL.drawElements(this.GL.TRIANGLES, Model.TriangleCount, this.GL.UNSIGNED_SHORT, 0);
 }
};複製代碼

你可容易地看到任何WebGL應用來學到更多。

下面是Draw()函數的剩下部分。它幾乎和以前同樣,只不過添加了連接法向量矩陣到着色器的代碼。如今,讓咱們回到用於計算法向變換矩陣的那兩個函數。

InverseMatrix()函數接受一個矩陣做爲參數,並返回其逆矩陣。一個矩陣的逆矩陣指的是,乘以原矩陣獲得一個單位矩陣。咱們用一點基礎的代數示例來解釋這一點。 數字4的逆是1/4,由於1/4 x 4=1。矩陣裏和"1"至關的是單位矩陣。於是,InverseMatrix()函數的返回值與參數的乘積是單位矩陣。下面是此函數:

function InverseMatrix(A) {
 var s0 = A[0] * A[5] - A[4] * A[1];
 var s1 = A[0] * A[6] - A[4] * A[2];
 var s2 = A[0] * A[7] - A[4] * A[3];
 var s3 = A[1] * A[6] - A[5] * A[2];
 var s4 = A[1] * A[7] - A[5] * A[3];
 var s5 = A[2] * A[7] - A[6] * A[3];
 var c5 = A[10] * A[15] - A[14] * A[11];
 var c4 = A[9] * A[15] - A[13] * A[11];
 var c3 = A[9] * A[14] - A[13] * A[10];
 var c2 = A[8] * A[15] - A[12] * A[11];
 var c1 = A[8] * A[14] - A[12] * A[10];
 var c0 = A[8] * A[13] - A[12] * A[9];
 var invdet = 1.0 / (s0 * c5 - s1 * c4 + s2 * c3 + s3 * c2 - s4 * c1 + s5 * c0);
 var B = [];
 B[0] = ( A[5] * c5 - A[6] * c4 + A[7] * c3) * invdet;
 B[1] = (-A[1] * c5 + A[2] * c4 - A[3] * c3) * invdet;
 B[2] = ( A[13] * s5 - A[14] * s4 + A[15] * s3) * invdet;
 B[3] = (-A[9] * s5 + A[10] * s4 - A[11] * s3) * invdet;
 B[4] = (-A[4] * c5 + A[6] * c2 - A[7] * c1) * invdet;
 B[5] = ( A[0] * c5 - A[2] * c2 + A[3] * c1) * invdet;
 B[6] = (-A[12] * s5 + A[14] * s2 - A[15] * s1) * invdet;
 B[7] = ( A[8] * s5 - A[10] * s2 + A[11] * s1) * invdet;
 B[8] = ( A[4] * c4 - A[5] * c2 + A[7] * c0) * invdet;
 B[9] = (-A[0] * c4 + A[1] * c2 - A[3] * c0) * invdet;
 B[10] = ( A[12] * s4 - A[13] * s2 + A[15] * s0) * invdet;
 B[11] = (-A[8] * s4 + A[9] * s2 - A[11] * s0) * invdet;
 B[12] = (-A[4] * c3 + A[5] * c1 - A[6] * c0) * invdet;
 B[13] = ( A[0] * c3 - A[1] * c1 + A[2] * c0) * invdet;
 B[14] = (-A[12] * s3 + A[13] * s1 - A[14] * s0) * invdet;
 B[15] = ( A[8] * s3 - A[9] * s1 + A[10] * s0) * invdet;
 return B;
}複製代碼

這個函數至關複雜,不妨偷偷告訴你,我並不徹底理解這背後的數學。但我已經爲你解釋了它的精髓。這個函數並非我寫的;它是Robin Hilliard用ActionScript寫的。

下一個函數MatrixTranspose()則簡單多了,它只不過返回一個輸入矩陣的"轉置"的版本。簡而言之,它將矩陣沿對角線轉了一下。下面是代碼:

function MatrixTranspose(A) {
 return [
 A[0], A[4], A[8], A[12],
 A[1], A[5], A[9], A[13],
 A[2], A[6], A[10], A[14],
 A[3], A[7], A[11], A[15]
 ];
}複製代碼

你可看到,通過轉置,原來的水平行 (A[0],A[1],A[2]...) 變成了豎直列,原來的豎直列 (A[0],A[4],A[8]...) 變成了水平行。

你能夠將這兩個函數添加到WebGL.js文件中去,而後,任何包含法向量數據的模型都會有光照效果。你能夠修改頂點着色器中的光照方向和顏色來獲得不一樣的效果。

我最後但願介紹的主題是在場景中添加2D內容。在3D場景中添加2D元素有不少好處。 好比,它可用於展現座標信息,一個縮略圖,應用的指令,以及其它信息。這個過程並非你想象那麼直接,因此,咱們仍是討論一下吧。

2D?仍是2.5D?

HTML不會讓你在同一個畫布 (canvas) 上使用WebGL API和2D API。

你可能會想,"爲什麼不用HTML5的畫布 (canvas) 的內置2D API"?緣由在於HTML不讓你在同一個畫布上同時使用WebGL API和2D API。 一量你將畫布 (canvas) 的上下文賦給WebGL以後,你不能再在它上面使用2D API。當你嘗試訪問2D上下文時,你獲得的null。因此,咱們怎麼解決這個問題呢?我能夠給你兩個選項:

2.5D

2.5D指的是將2D對象 (沒有深度的對象) 添加到3D場景中。在場景中添加文字是2.5D的一個例子。 你能夠將文字寫到一幅圖中,而後將圖片用做紋理貼到3D平面上,或者,你能夠構造一個文字的3D模型,而後在屏幕上渲染。

這種方法的好處在於,你不須要兩個畫布 (canvas),並且若是你只用簡單的形狀,它的繪製效率也會很高。

可是,爲了處理文字,要麼你爲每一個句話都準備圖片,要麼你爲每一個字建一個3D模型 (我以爲有點誇張了)。

2D

另外一種方法是生成第二個畫布 (canvas),將它覆蓋在3D畫布上。我傾向於這種方法,由於它看上去更適於繪製2D內容。 我不會開始造一個新的2D框架,可是咱們能夠用一個簡單例子來顯示模型在當前旋轉狀況下的座標信息。 讓咱們在HTML文件中添加第二個畫布,就放在WebGL畫布的後面。下面是當前畫布和新畫布的代碼:

<canvas id="GLCanvas" width="600" height="400" style="position:absolute; top:0px; left:0px;">  
 Your Browser Doesn't Support HTML5's Canvas.  
</canvas>  
<canvas id="2DCanvas" width="600" height="400" style="position:absolute; top:0px; left:0px;">
 Your Browser Doesn't Support HTML5's Canvas.
</canvas>複製代碼

我還添加了一些行內的CSS代碼,以讓第二個畫布覆蓋在第一個上。下一步是用一個變量來獲取這個2D畫布的上下文。 我將在Ready()函數中實現這一點。你的修改後的代碼應該像下面這樣:

var GL; 
var Building;
var Canvas2D;
function Ready(){
 //Gl Declaration and Load model function Here
 Canvas2D = document.getElementById("2DCanvas").getContext("2d");
 Canvas2D.fillStyle="#000";
}複製代碼

在頂部,你可看到我添加了2D畫布的全局的變量。而後,我在Ready()函數的底部添加了兩行。第一行取得2D上下文,第二行設置顏色爲黑色。

最後一步是在Update()函數內繪製文本。

function Update(){
 Building.Rotation.Y += 0.3
 //Clear the Canvas from the previous draw
 Canvas2D.clearRect(0, 0, 600, 400);
 //Title Text
 Canvas2D.font="25px sans-serif";
 Canvas2D.fillText("Building" , 20, 30);
 //Object's Properties Canvas2D.font="16px sans-serif"; Canvas2D.fillText("X : " + Building.Pos.X , 20, 55); Canvas2D.fillText("Y : " + Building.Pos.Y , 20, 75); Canvas2D.fillText("Z : " + Building.Pos.Z , 20, 95); Canvas2D.fillText("Rotation : " + Math.floor(Building.Rotation.Y) , 20, 115); GL.GL.clear(16384 | 256); GL.Draw(Building); }複製代碼

咱們首先讓模型繞Y軸旋轉,而後咱們清除2D畫布以前的內容。接下來,咱們設置字體大小,併爲每一個座標軸繪製文本。 fillText()方法接受參數:待繪製文本,x座標,y座標。

此方法的簡潔性顯而易見。爲了畫一些文字而這樣作彷佛有些小題大作;你盡能夠在指定了位置的<div/><p/>元素中寫一些文字。 可是,若是你要畫一些形狀,螺線,或一個健康顯示條,等等,此方法極可能是你最好的選擇。

最後的思考

在這三個教程中,咱們建立了一個很是漂亮,但又比較基礎的3D引擎。雖然還比較原始,但它爲咱們進一步前行打下了堅實的基礎。 若繼續前行,我建議瞭解一下其它的框架,好比three.js或gige,從它們那兒能夠了解有哪些可行性。此外,WebGL在瀏覽器中運行,你老是能夠經過查看其源碼來學到更多。

更多精彩內容,請微信關注」前端達人」公衆號!

原文連接:https://code.tutsplus.com/tutorials/webgl-essentials-part-iii--net-27694

原文做者:Gabriel Manricks

相關文章
相關標籤/搜索