一篇通俗易懂的講解OpenGL ES的文章,附動態改變圖片飽和度代碼

原文地址: https://www.cnblogs.com/salam/archive/2016/01/08/5113572.html
對照原文, 實現了一份示例代碼, 放在https://github.com/shaopx/opengl_image_saturation_demo

電腦或者手機上做圖像處理有很多方式,但是目前為止最高效的方法是有效地使用圖形處理單元,或者叫 GPU。你的手機包含兩個不同的處理單元,CPU 和 GPU。CPU 是個多面手,并且不得不處理所有的事情,而 GPU 則可以集中來處理好一件事情,就是并行地做浮點運算。事實上,圖像處理和渲染就是在將要渲染到窗口上的像素上做許許多多的浮點運算。

通過有效的利用 GPU,可以成百倍甚至上千倍地提高手機上的圖像渲染能力。如果不是基于 GPU 的處理,手機上實時高清視頻濾鏡是不現實,甚至不可能的。

著色器 (shader) 是我們利用這種能力的工具。著色器是用著色語言寫的小的,基于 C 語言的程序。現在有很許多種著色語言,但你如果做 OS X 或者 iOS 開發的話,你應該專注于 OpenGL 著色語言,或者叫 GLSL。你可以將 GLSL 的理念應用到其他的更專用的語言 (比如 Metal) 上去。這里我們即將介紹的概念與和 Core Image 中的自定義核矩陣有著很好的對應,盡管它們在語法上有一些不同。

這個過程可能會很讓人恐懼,尤其是對新手。這篇文章的目的是讓你接觸一些寫圖像處理著色器的必要的基礎信息,并將你帶上書寫你自己的圖像處理著色器的道路。
什么是著色器

在 OpenGL ES 中你必須創建兩種著色器:頂點著色器 (vertex shaders) 和片段著色器 (fragment shaders)。這兩種著色器是一個完整程序的兩半,你不能僅僅創建其中任何一個;想創建一個完整的著色程序,兩個都是必須存在。

頂點著色器定義了在 2D 或者 3D 場景中幾何圖形是如何處理的。一個頂點指的是 2D 或者 3D 空間中的一個點。在圖像處理中,有 4 個頂點:每一個頂點代表圖像的一個角。頂點著色器設置頂點的位置,并且把位置和紋理坐標這樣的參數發送到片段著色器。

然后 GPU 使用片段著色器在對象或者圖片的每一個像素上進行計算,最終計算出每個像素的最終顏色。圖片,歸根結底,實際上僅僅是數據的集合。圖片的文檔包含每一個像素的各個顏色分量和像素透明度的值。因為對每一個像素,算式是相同的,GPU 可以流水線作業這個過程,從而更加有效的進行處理。使用正確優化過的著色器,在 GPU 上進行處理,將使你獲得百倍于在 CPU 上用同樣的過程進行圖像處理的效率。

我們的第一個著色器的例子

頂點著色器

好吧,關于著色器我們說的足夠多了。我們來看一個實踐中真實的著色器程序。這里是一個 GPUImage 中一個基礎的頂點著色器:

NSString *const kGPUImageVertexShaderString = SHADER_STRING
(
 attribute vec4 position;
 attribute vec4 inputTextureCoordinate;
 
 varying vec2 textureCoordinate;
 
 void main()
 {
     gl_Position = position;
     textureCoordinate = inputTextureCoordinate.xy;
 }
 );

我們一句一句的來看:

attribute vec4 position;

像所有的語言一樣,著色器語言的設計者也為常用的類型創造了特殊的數據類型,例如 2D 和 3D 坐標。這些類型是向量,稍后我們會深入更多。回到我們的應用程序的代碼,我們創建了一系列頂點,我們為每個頂點提供的參數里的其中一個是頂點在畫布中的位置。然后我們必須告訴我們的頂點著色器它需要接收這個參數,我們稍后會將它用在某些事情上。

attribute vec4 inputTextureCoordinate;

現在你或許很奇怪,為什么我們需要一個紋理坐標。我們不是剛剛得到了我們的頂點位置了嗎?難道它們不是同樣的東西嗎?

其實它們并非一定是同樣的東西。紋理坐標是紋理映射的一部分。這意味著你想要對你的紋理進行某種濾鏡操作的時候會用到它。左上角坐標是 (0,0)。右上角的坐標是 (1,0)。如果我們需要在圖片內部而不是邊緣選擇一個紋理坐標,我們需要在我們的應用中設定的紋理坐標就會與此不同,像是 (.25, .25) 是在圖片左上角向右向下各圖片高寬 1/4 的位置。在我們當前的圖像處理應用里,我們希望紋理坐標和頂點位置一致,因為我們想覆蓋到圖片的整個長度和寬度。有時候你或許會希望這些坐標是不同的,所以需要記住它們未必是相同的坐標。在這個例子中,頂點坐標空間從 -1.0 延展到 1.0,而紋理坐標是從 0.0 到 1.0。

varying vec2 textureCoordinate;

因為頂點著色器負責和片段著色器交流,所以我們需要創建一個變量和它共享相關的信息。在圖像處理中,片段著色器需要的唯一相關信息就是頂點著色器現在正在處理哪個像素。

如果你使用最新的opengl es版本, 這個關鍵字varying 已經取消了. 2008.8.11 OGL3.0發布,伴隨GLSL1.30.10, 其中的attribute varying等就已經改成了 in和out; 為了保持原文的一致性, 這里仍然使用varying關鍵字.

gl_Position = position;

gl_Position 是一個內建的變量。GLSL 有一些內建的變量,在片段著色器的例子中我們將看到其中的一個。這些特殊的變量是可編程管道的一部分,API 會去尋找它們,并且知道如何和它們關聯上。在這個例子中,我們指定了頂點的位置,并且把它從我們的程序中反饋給渲染管線。

textureCoordinate = inputTextureCoordinate.xy;

最后,我們取出這個頂點中紋理坐標的 X 和 Y 的位置。我們只關心 inputTextureCoordinate 中的前兩個參數,X 和 Y。這個坐標最開始是通過 4 個屬性存在頂點著色器里的,但我們只需要其中的兩個。我們拿出需要的屬性,然后賦值給一個將要和片段著色器通信的變量,而不是把更多的屬性反饋給片段著色器。

在大多數圖像處理程序中,頂點著色器都差不多,所以,這篇文章接下來的部分,我們將集中討論片段著色器。

片段著色器

看過了我們簡單的頂點著色器后,我們再來看一個可以實現的最簡單的片段著色器:一個直通濾鏡:

varying highp vec2 textureCoordinate;
 
uniform sampler2D inputImageTexture;
 
void main()  
{
    gl_FragColor = texture2D(inputImageTexture, textureCoordinate);
}

這個著色器實際上不會改變圖像中的任何東西。它是一個直通著色器,意味著我們輸入每一個像素,然后輸出完全相同的像素。我們來一句句的看:

varying highp vec2 textureCoordinate;

因為片段著色器作用在每一個像素上,我們需要一個方法來確定我們當前在分析哪一個像素/片段。它需要存儲像素的 X 和 Y 坐標。我們接收到的是當前在頂點著色器被設置好的紋理坐標。

uniform sampler2D inputImageTexture;

為了處理圖像,我們從應用中接收一個圖片的引用,我們把它當做一個 2D 的紋理。這個數據類型被叫做 sampler2D ,這是因為我們要從這個 2D 紋理中采樣出一個點來進行處理。

gl_FragColor = texture2D(inputImageTexture, textureCoordinate);

這是我們碰到的第一個 GLSL 特有的方法:texture2D,顧名思義,創建一個 2D 的紋理。它采用我們之前聲明過的屬性作為參數來決定被處理的像素的顏色。這個顏色然后被設置給另外一個內建變量,gl_FragColor。因為片段著色器的唯一目的就是確定一個像素的顏色,gl_FragColor 本質上就是我們片段著色器的返回語句。一旦這個片段的顏色被設置,接下來片段著色器就不需要再做其他任何事情了,所以你在這之后寫任何的語句,都不會被執行。

就像你看到的那樣,寫著色器很大一部分就是了解著色語言。即使著色語言是基于 C 語言的,依然有很多怪異和細微的差別讓它和普通的 C 語言有不同。

輸入,輸出,以及精度修飾 (Precision Qualifiers)

看一看我們的直通著色器,你會注意到有一個屬性被標記為 “varying”,另一個屬性被標記為 “uniform”。

這些變量是 GLSL 中的輸入和輸出。它允許從我們應用的輸入,以及在頂點著色器和片段著色器之間進行交流。

在 GLSL 中,實際有三種標簽可以賦值給我們的變量:

  • Uniforms
  • Attributes
  • Varyings

再一次聲明, Varyings 已經作廢, 請使用 in out關鍵字.

Uniforms 是一種外界和你的著色器交流的方式。Uniforms 是為在一個渲染循環里不變的輸入值設計的。如果你正在應用茶色濾鏡,并且你已經指定了濾鏡的強度,那么這些就是在渲染過程中不需要改變的事情,你可以把它作為 Uniform 輸入。 Uniform 在頂點著色器和片段著色器里都可以被訪問到。

Attributes 僅僅可以在頂點著色器中被訪問。Attribute 是在隨著每一個頂點不同而會發生變動的輸入值,例如頂點的位置和紋理坐標等。頂點著色器利用這些變量來計算位置,以它們為基礎計算一些值,然后把這些值以 varyings 的方式傳到片段著色器。

最后,但同樣重要的,是 varyings 標簽。Varying 在頂點著色器和片段著色器都會出現。Varying 是用來在頂點著色器和片段著色器傳遞信息的,并且在頂點著色器和片段著色器中必須有匹配的名字。數值在頂點著色器被寫入到 varying ,然后在片段著色器被讀出。被寫入 varying 中的值,在片段著色器中會被以插值的形式插入到兩個頂點直接的各個像素中去。

回看我們之前寫的簡單的著色器的例子,在頂點著色器和片段著色器中都用 varying 聲明了 textureCoordinate。我們在頂點著色器中寫入 varying 的值。然后我們把它傳入片段著色器,并在片段著色器中讀取和處理。

在我們繼續之前,最后一件要注意的事。看看創建的這些變量。你會注意到紋理坐標有一個叫做 highp 的屬性。這個屬性負責設置你需要的變量精度。因為 OpenGL ES 被設計為在處理能力有限的系統中使用,精度限制被加入進來可以提高效率。

如果不需要非常高的精度,你可以進行設定,這或許會允許在一個時鐘循環內處理更多的值。相反的,在紋理坐標中,我們需要盡可能的確保精確,所以我們具體說明確實需要額外的精度。

精度修飾存在于 OpenGL ES 中,因為它是被設計用在移動設備中的。但是,在老版本的桌面版的 OpenGL 中則沒有。因為 OpenGL ES 實際上是 OpenGL 的子集,你幾乎總是可以直接把 OpenGL ES 的項目移植到 OpenGL。如果你這樣做,記住一定要在你的桌面版著色器中去掉精度修飾。這是很重要的一件事,尤其是當你計劃在 iOS 和 OS X 之間移植項目時。

向量

在 GLSL 中,你會用到很多向量和向量類型。向量是一個很棘手的話題,它們表面上看起來很直觀,但是因為它們有很多用途,這使我們在使用它們時常常會感到迷惑。

在 GLSL 環境中,向量是一個類似數組的特殊的數據類型。每一種類型都有固定的可以保存的元素。深入研究一下,你甚至可以獲得數組可以存儲的數值的精確的類型。但是在大多數情況下,只要使用通用的向量類型就足夠了。

有三種向量類型你會經常看到:

  • vec2
  • vec3
  • vec4

這些向量類型包含特定數量的浮點數:vec2 包含兩個浮點數,vec3 包含三個浮點數,vec4 包含四個浮點數。

這些類型可以被用在著色器中可能被改變或者持有的多種數據類型中。在片段著色器中,很明顯 X 和 Y 坐標是的你想保存的信息。 (X,Y) 存儲在 vec2 中就很合適。

在圖像處理過程中,另一個你可能想持續追蹤的事情就是每個像素的 R,G,B,A 值。這些可以被存儲在 vec4 中。

矩陣

現在我們已經了解了向量,接下來繼續了解矩陣。矩陣和向量很相似,但是它們添加了額外一層的復雜度。矩陣是一個浮點數數組的數組,而不是單個的簡單浮點數數組。

類似于向量,你將會經常處理的矩陣對象是:

  • mat2
  • mat3
  • mat4

vec2 保存兩個浮點數,mat 保存相當于兩個 vec2 對象的值。將向量對象傳遞到矩陣對象并不是必須的,只需要有足夠填充矩陣的浮點數即可。在 mat2 中,你需要傳入兩個 vec2 或者四個浮點數。因為你可以給向量命名,而且相比于直接傳浮點數,你只需要負責兩個對象,而不是四個,所以非常推薦使用封裝好的值來存儲你的數字,這樣更利于追蹤。對于 mat4 會更復雜一些,因為你要負責 16 個數字,而不是 4 個。

在我們 mat2 的例子中,我們有兩個 vec2 對象。每個 vec2 對象代表一行。每個 vec2 對象的第一個元素代表一列。構建你的矩陣對象的時候,確保每個值都放在了正確的行和列上是很重要的,否則使用它們進行運算肯定得不到正確的結果。

既然我們有了矩陣也有了填充矩陣的向量,問題來了:“我們要用它們做什么呢?“ 我們可以存儲點和顏色或者其他的一些的信息,但是要如果通過修改它們來做一些很酷的事情呢?

向量和矩陣運算,也就是初等線性代數

我找到的最好的關于線性代數和矩陣是如何工作的資源是這個網站的更好的解釋。我從這個網站偷來借鑒的一句引述就是:

線性代數課程的幸存者都成為了物理學家,圖形程序員或者其他的受虐狂。

矩陣操作總體來說并不“難”;只不過它們沒有被任何上下文解釋,所以很難概念化地理解究竟為什么會有人想要和它們打交道。我希望能在給出一些它們在圖形編程中的應用背景后,我們可以了解它們怎樣幫助我們實現不可思議的東西。

線性代數允許你一次在很多值上進行操作。假想你有一組數,你想要每一個數乘以 2。你一般會一個個地順次計算數值。但是因為對每一個數都進行的是同樣的操作,所以你完全可以并行地實現這個操作。

我們舉一個看起來可怕的例子,CGAffineTransforms。仿射轉化是很簡單的操作,它可以改變具有平行邊的形狀 (比如正方形或者矩形) 的大小,位置,或者旋轉角度。

在這種時候你當然可以坐下來拿出筆和紙,自己去計算這些轉化,但這么做其實沒什么意義。GLSL 有很多內建的函數來進行這些龐雜的用來計算轉換的函數。了解這些函數背后的思想才是最重要的。

GLSL 特有函數

這篇文章中,我們不會把所有的 GLSL 內建的函數都過一遍,不過你可以在 Shaderific 上找到很好的相關資源。很多 GLSL 函數都是從 C 語言數學庫中的基本的數學運算導出的,所以解釋 sin 函數是做什么的真的是浪費時間。我們將集中闡釋一些更深奧的函數,從而達到這篇文章的目的,解釋怎樣才能充分利用 GPU 的性能的一些細節。

step(): GPU 有一個局限性,它并不能很好的處理條件邏輯。GPU 喜歡做的事情是接受一系列的操作,并將它們作用在所有的東西上。分支會在片段著色器上導致明顯的性能下降,在移動設備上尤其明顯。step() 通過允許在不產生分支的前提下實現條件邏輯,從而在某種程度上可以緩解這種局限性。如果傳進 step() 函數的值小于閾值,step() 會返回 0.0。如果大于或等于閾值,則會返回 1.0。通過把這個結果和你的著色器的值相乘,著色器的值就可以被使用或者忽略,而不用使用 if() 語句。

mix(): mix 函數將兩個值 (例如顏色值) 混合為一個變量。如果我們有紅和綠兩個顏色,我們可以用 mix() 函數線性插值。這在圖像處理中很常用,比如在應用程序中通過一組獨特的設定來控制效果的強度等。

*clamp(): GLSL 中一個比較一致的方面就是它喜歡使用歸一化的坐標。它希望收到的顏色分量或者紋理坐標的值在 0.0 和 1.0 之間。為了保證我們的值不會超出這個非常窄的區域,我們可以使用 clamp() 函數。 clamp() 會檢查并確保你的值在 0.0 和 1.0 之間。如果你的值小于 0.0,它會把值設為 0.0。這樣做是為了防止一些常見的錯誤,例如當你進行計算時意外的傳入了一個負數,或者其他的完全超出了算式范圍的值。

更復雜的著色器的例子

我知道數學的洪水一定讓你快被淹沒了。如果你還能跟上我,我想舉幾個優美的著色器的例子,這會更有意義,這樣你又有機會淹沒在 GLSL 的潮水中。

飽和度調整
image.png

這是一個做飽和度調節的片段著色器。這個著色器出自 《圖形著色器:理論和實踐》一書,我強烈推薦整本書給所有對著色器感興趣的人。

飽和度是用來表示顏色的亮度和強度的術語。一件亮紅色的毛衣的飽和度要遠比北京霧霾時灰色的天空的飽和度高得多。

在這個著色器上,參照人類對顏色和亮度的感知過程,我們有一些優化可以使用。一般而言,人類對亮度要比對顏色敏感的多。這么多年來,壓縮軟件體積的一個優化方式就是減少存儲顏色所用的內存。

人類不僅對亮度比顏色要敏感,同樣亮度下,我們對某些特定的顏色反應也更加靈敏,尤其是綠色。這意味著,當你尋找壓縮圖片的方式,或者以某種方式改變它們的亮度和顏色的時候,多放一些注意力在綠色光譜上是很重要的,因為我們對它最為敏感。

varying highp vec2 textureCoordinate;
 
 uniform sampler2D inputImageTexture;
 uniform lowp float saturation;
 
 // Values from "Graphics Shaders: Theory and Practice" by Bailey and Cunningham
 const mediump vec3 luminanceWeighting = vec3(0.2125, 0.7154, 0.0721);
 
 void main()
 {
    lowp vec4 textureColor = texture2D(inputImageTexture, textureCoordinate);
    lowp float luminance = dot(textureColor.rgb, luminanceWeighting);
    lowp vec3 greyScaleColor = vec3(luminance);
    
    gl_FragColor = vec4(mix(greyScaleColor, textureColor.rgb, saturation), textureColor.w);
     
 }

我們一行行的看這個片段著色器的代碼:

varying highp vec2 textureCoordinate;
 
uniform sampler2D inputImageTexture;  
uniform lowp float saturation;

再一次,因為這是一個要和基礎的頂點著色器通信的片段著色器,我們需要為輸入紋理坐標和輸入圖片紋理聲明一個 varyings 變量,這樣才能接收到我們需要的信息,并進行過濾處理。這個例子中我們有一個新的 uniform 的變量需要處理,那就是飽和度。飽和度的數值是一個我們從用戶界面設置的參數。我們需要知道用戶需要多少飽和度,從而展示正確的顏色數量。

const mediump vec3 luminanceWeighting = vec3(0.2125, 0.7154, 0.0721);

這就是我們設置三個元素的向量,為我們的亮度來保存顏色比重的地方。這三個值加起來要為 1,這樣我們才能把亮度計算為 0.0 – 1.0 之間的值。注意中間的值,就是表示綠色的值,用了 70% 的顏色比重,而藍色只用了它的 10%。藍色對我們的展示不是很好,把更多權重放在綠色上是很有意義的。

lowp vec4 textureColor = texture2D(inputImageTexture, textureCoordinate);

我們需要取樣特定像素在我們圖片/紋理中的具體坐標來獲取顏色信息。我們將會改變它一點點,而不是想直通濾鏡那樣直接返回。

lowp float luminance = dot(textureColor.rgb, luminanceWeighting);

這行代碼會讓那些沒有學過線性代數或者很早以前在學校學過但是很少用過的人看起來不那么熟悉。我們是在使用 GLSL 中的點乘運算。如果你記得在學校里曾用過點運算符來相乘兩個數字的話,那么你就能明白是什么回事兒了。點乘計算以包含紋理顏色信息的vec4 為參數,舍棄 vec4 的最后一個不需要的元素,將它和相對應的亮度權重相乘。然后取出所有的三個值把它們加在一起,計算出這個像素綜合的亮度值。

lowp vec3 greyScaleColor = vec3(luminance);

為什么又聲明了一個vec3變量, 是為了后面調用mix函數時必須提供一個vec3的變量.

mix(greyScaleColor, textureColor.rgb, saturation)

關于mix()函數的簽名: anyFLoat mix(anyFLoat x,anyFloat y,anyFloat a) 返回x和y的線性混合,a從0到1變化

最后,我們把所有的片段組合起來。為了確定每個新的顏色是什么,我們使用剛剛學過的很好用的 mix 函數。mix 函數會把我們剛剛計算的灰度值和初始的紋理顏色以及我們得到的飽和度的信息相結合。

這就是一個很棒的,好用的著色器,它讓你用主函數里的四行代碼就可以把圖片從彩色變到灰色,或者從灰色變到彩色。還不錯,不是嗎?

我仍然不理解最后一句:gl_FragColor = vec4(mix(greyScaleColor, textureColor.rgb, saturation), textureColor.w); 最后的那個textureColor.w是哪來的, 如果按照xyzw的方式標記這個vec4變量, 那前面就不改使用rgb了, 如果前面使用了rgb這里不應該使用a嗎?

demo

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