【OpenGL ES】入門及繪制一個(gè)三角形

本文首發(fā)于個(gè)人博客:Lam's Blog - 【OpenGL ES】入門及繪制一個(gè)三角形,文章由MarkDown語法編寫,可能不同平臺(tái)渲染效果不一,如果有存在排版錯(cuò)誤圖片無法顯示等問題,煩請(qǐng)移至個(gè)人博客,如果個(gè)人博客無法訪問可以留言告訴我,轉(zhuǎn)載請(qǐng)聲明個(gè)人博客出處,謝謝。

簡介

OpenGL

OpenGL(全寫Open Graphics Library)是指定義了一個(gè)跨編程語言、跨平臺(tái)的編程接口規(guī)格的專業(yè)的圖形程序接口。它用于三維圖像(二維亦可),是一個(gè)功能強(qiáng)大,調(diào)用方便的底層圖形庫。此處L代表的是Library而不是Language。

OpenGL在不同的平臺(tái)上有不同的實(shí)現(xiàn),但是它定義好了專業(yè)的程序接口,不同的平臺(tái)都是遵照該接口來進(jìn)行實(shí)現(xiàn)的,思想完全相同,方法名也是一致的,所以使用時(shí)也基本一致,只需要根據(jù)不同的語言環(huán)境稍有不同而已。

OpenGL ES

OpenGL ES (OpenGL for Embedded Systems) 是 OpenGL 三維圖形 API 的子集,針對(duì)手機(jī)、PDA和游戲主機(jī)等嵌入式設(shè)備而設(shè)計(jì)。

OpenGL ES相對(duì)于OpenGL來說,減少了許多不是必須的方法和數(shù)據(jù)類型,去掉了不必須的功能,對(duì)代價(jià)大的功能做了限制,比OpenGL更為輕量。在OpenGL ES的世界里,沒有四邊形、多邊形,無論多復(fù)雜的圖形都是由點(diǎn)、線和三角形組成的,也去除了glBegin/glEnd等方法。

EGL

EGL 是 OpenGL ES 渲染 API 和本地窗口系統(tǒng)(native platform window system)之間的一個(gè)中間接口層,它主要由系統(tǒng)制造商實(shí)現(xiàn)。

EGL提供如下機(jī)制:

  • 與設(shè)備的原生窗口系統(tǒng)通信
  • 查詢繪圖表面的可用類型和配置
  • 創(chuàng)建繪圖表面
  • 在OpenGL ES 和其他圖形渲染API之間同步渲染
  • 管理紋理貼圖等渲染資源
  • 為了讓OpenGL ES能夠繪制在當(dāng)前設(shè)備上,我們需要EGL作為OpenGL ES與設(shè)備的橋梁。

在Android上層是用GLSurfaceView可以方便快捷地結(jié)合Renderer進(jìn)行渲染,GLSurfaceView的源碼實(shí)現(xiàn)其實(shí)就是創(chuàng)建與使用EGL(Display、Context等等)的過程。而當(dāng)需要與相機(jī)或MediaCodec進(jìn)行結(jié)合渲染時(shí),就需要直接與EGL打交道。GLSurfaceView只是幫我們做了一些事情而已。

ES與EGL的關(guān)系

我們來思考一下畫家繪畫的過程:首先要有一名懂得各種繪畫技藝的畫家,然后他需要一張畫布,一些筆,一些顏料,一些輔助工具(尺、模板、橡皮、調(diào)色板等等),然后他在畫布上繪制第一幅畫,完成之后展示給人們看;在人們觀賞第一幅畫的時(shí)候,他可以在第二張畫布上繪制第二幅畫,繪制完成后收回第一幅畫,將第二幅畫展現(xiàn)給人們看;接著使用工具擦除第一幅畫,在同一張畫布上繪制第三幅畫;周而復(fù)始,人們便看到了一幅接一幅的畫(這里就涉及到離屏渲染的概念,會(huì)在后面FBO相關(guān)章節(jié)中講述)。

對(duì)比 OpenGL ES/EGL,各要素的對(duì)應(yīng)關(guān)系大體如下:

  • 畫家:編程人員
  • 筆、顏料、輔助工具:OpenGL ES API
  • 畫布:EGL 創(chuàng)建的 Surface

所以計(jì)算機(jī)繪畫的本質(zhì)就是選擇圖像顯示的像素格式,申請(qǐng)一塊內(nèi)存(畫布),填充像素(顏色),繪制完成之后,通知計(jì)算機(jī)顯示到屏幕上(按比例發(fā)射RGB光),最終就看到了所繪制的畫面。之所以要先選擇像素格式,是因?yàn)闊o論是所申請(qǐng)內(nèi)存的大小,還是硬件驅(qū)動(dòng)解析這塊內(nèi)存的方式,都是由像素格式?jīng)Q定的。

為什么要使用OpenGL ES

通常來說,計(jì)算機(jī)系統(tǒng)中 CPU、GPU 是協(xié)同工作的。CPU 計(jì)算好顯示內(nèi)容提交到 GPU,GPU 渲染完成后將渲染結(jié)果放入幀緩沖區(qū),隨后視頻控制器會(huì)按照 VSync 信號(hào)逐行讀取幀緩沖區(qū)的數(shù)據(jù),經(jīng)過可能的數(shù)模轉(zhuǎn)換傳遞給顯示器顯示。所以,盡可能讓 CPU 和 GPU 各司其職發(fā)揮作用是提高渲染效率的關(guān)鍵。

正如我們之前提到過,OpenGL 正是給我們提供了訪問 GPU 的能力,不僅如此,它還引入了緩存(Buffer)這個(gè)概念,大大提高了處理效率。

<center>{% qnimg 20170111148411700373589.jpg %}</center>

圖中的剪頭,代表著數(shù)據(jù)交換,也是主要的性能瓶頸。

從一個(gè)內(nèi)存區(qū)域復(fù)制到另一個(gè)內(nèi)存區(qū)域的速度是相對(duì)較慢的,并且在內(nèi)存復(fù)制的過程中,CPU 和 GPU 都不能處理這區(qū)域內(nèi)存,避免引起錯(cuò)誤。此外,CPU / GPU 執(zhí)行計(jì)算的速度是很快的,而內(nèi)存的訪問是相對(duì)較慢的,這也導(dǎo)致處理器的性能處于次優(yōu)狀態(tài),這種狀態(tài)叫做 數(shù)據(jù)饑餓,簡單來說就是空有一身本事卻無用武之地。

針對(duì)此,OpenGL 為了提升渲染的性能,為兩個(gè)內(nèi)存區(qū)域間的數(shù)據(jù)交換定義了緩存。緩存是指 GPU 能夠控制和管理的連續(xù) RAM。程序從 CPU 的內(nèi)存復(fù)制數(shù)據(jù)到 OpenGL ES 的緩存。通過獨(dú)占緩存,GPU 能夠盡可能以有效的方式讀寫內(nèi)存。 這也意味著 GPU 使用緩存中的數(shù)據(jù)工作的同時(shí),運(yùn)行在 CPU 中的程序可以繼續(xù)執(zhí)行。

總結(jié)

OpenGL的使用需要涉及著色器語言,但是其自身并不是一種語言,嚴(yán)格來說它本身只是一個(gè)協(xié)議規(guī)范,定義了一套可以供上層應(yīng)用程序進(jìn)行調(diào)用的 API,它抽象了 GPU 的功能,使應(yīng)用開發(fā)者不必關(guān)心底層的 GPU 類型和具體實(shí)現(xiàn)。而EGL就是OpenGL與本地窗口系統(tǒng)之間的一個(gè)抽象的中間接口層。

即使在我們?nèi)粘ndroid開發(fā)中沒有直接與OpenGL進(jìn)行接觸,但是在Android的繪制實(shí)現(xiàn)中其實(shí)就是通過EGL來直接進(jìn)行的,例如查看SurfaceFlinger的native層代碼就可以發(fā)現(xiàn)其內(nèi)部就有許多相關(guān)操作。

OpenGL ES 目前最新版本為3.0,3.0支持了新版的著色語言,紋理MSAA抗鋸齒等強(qiáng)大功能,但是目前大多數(shù)在用的仍然是2.0版本,也出于主流設(shè)備的考慮,本系列教程將基于2.0進(jìn)行,同時(shí)暫不涉及對(duì)1.x固定管線渲染方式(簡單講就是2.0通過頂點(diǎn)著色器取代了OpenGL ES 1.x中的變換和光照階段片元著色器取代了紋理顏色和環(huán)境求和Alpha測(cè)試等階段。這使得原來由OpenGL ES 1.x固定的階段需要用戶自己開發(fā)著色器處理,雖然在一定的程度上增加了代碼復(fù)雜度,但是靈活性卻大大增加,同時(shí)也能夠處理OpenGL ES 1.x中難以完成的處理任務(wù)。)的介紹以及開發(fā)環(huán)境的搭建。但是這里仍然附上經(jīng)典的固定渲染圖與可編程渲染管線圖:

<center>{% qnimg gl_pipeline.jpg title="渲染管線" %}</center>

OpenGL ES可以做什么

  • 圖片處理。比如圖片色調(diào)轉(zhuǎn)換、美顏等。
  • 攝像頭預(yù)覽效果處理。比如美顏相機(jī)、惡搞相機(jī)等。
  • 視頻處理。攝像頭預(yù)覽效果處理可以,這個(gè)自然也不在話下了。
  • 3D游戲。比如神廟逃亡、都市賽車等。
  • 。。。

OpenGL ES 2.0 基本概念

圖元

在OpenGL中,任何復(fù)雜的三維模型都是由基本的幾何圖元:點(diǎn)、線段和多邊形組成的,有了這些圖元,就可以建立比較復(fù)雜的模型。所有的圖元都是由一系列有順序的頂點(diǎn)集合來描述的。OpenGL ES中的圖元只有點(diǎn)、線、三角形,精簡了多邊形等其他圖元,各種復(fù)雜的幾何形狀都是由三角形構(gòu)成的。包括正方形、圓形、正方體、球體等。但是其他更為復(fù)雜的物體,我們不可能都自己去用三角形構(gòu)建,這個(gè)時(shí)候就需要通過加載利用其他軟件(比如3DMax)構(gòu)建的3D模型。之后在模型加載章節(jié)再詳細(xì)講述。

<center>{% qnimg 20170112148420555397978.jpg %}</center>

紋理

紋理是一個(gè)用來保存圖像的色值的 OpenGL ES 緩存

現(xiàn)實(shí)生活中,紋理最通常的作用是裝飾我們的物體模型,它就像是貼紙一樣貼在物體表面,使得物體表面擁有圖案。

但實(shí)際上在 OpenGL 中,紋理的作用不僅限于此,它可以用來存儲(chǔ)大量的數(shù)據(jù)。一個(gè)典型的例子就是利用紋理存儲(chǔ)畫筆筆刷的mask信息。

紋理坐標(biāo)在 x 和 y 軸上,范圍為 0 到 1 之間(我們使用的是 2D 紋理圖像)。使用紋理坐標(biāo)獲取紋理顏色叫做采樣。紋理坐標(biāo)起始于(0, 0),也就是紋理圖片的左下角,終始于(1, 1),即紋理圖片的右上角。下面的圖片展示了我們是如何把紋理坐標(biāo)映射到三角形上。

<center>{% qnimg 20170116148453793035367.jpg %}</center>

正因?yàn)榧y理坐標(biāo)的與眾不同,所以O(shè)penGL紋理渲染中有一種常見的“BUG”,就是紋理顛倒(垂直鏡像翻轉(zhuǎn)),有興趣的同學(xué)可以自己想想為什么會(huì)出現(xiàn)這種現(xiàn)象,解決方案很簡單,通過垂直鏡像翻轉(zhuǎn)我們的紋理或者頂點(diǎn)坐標(biāo)就可以解決,后續(xù)在FBO中有涉及到再講述。

著色器

著色器(Shader)是在GPU上運(yùn)行的小程序。從名稱可以看出,可通過處理它們來處理頂點(diǎn)。此程序使用OpenGL ES SL語言來編寫。它是一個(gè)描述頂點(diǎn)或像素特性的簡單程序。

頂點(diǎn)著色器

頂點(diǎn)著色器對(duì)每個(gè)頂點(diǎn)執(zhí)行一次運(yùn)算,它可以使用頂點(diǎn)數(shù)據(jù)來計(jì)算該頂點(diǎn)的坐標(biāo),顏色,光照,紋理坐標(biāo)等,在渲染管線中每個(gè)頂點(diǎn)都是獨(dú)立地被執(zhí)行。

在頂點(diǎn)著色器中最重要的任務(wù)是執(zhí)行頂點(diǎn)坐標(biāo)變換,應(yīng)用程序中設(shè)置的圖元頂點(diǎn)坐標(biāo)通常是針對(duì)本地坐標(biāo)系的。本地坐標(biāo)系簡化了程序中的坐標(biāo)計(jì)算,但是 GL 并不識(shí)別本地坐標(biāo)系,所以在頂點(diǎn)著色器中要對(duì)本地坐標(biāo)執(zhí)行模型視圖變換,將本地坐標(biāo)轉(zhuǎn)化為裁剪坐標(biāo)系的坐標(biāo)值。

頂點(diǎn)著色器的另一個(gè)功能是向后面的片段著色器提供一組易變變量(varying)。易變變量會(huì)在圖元裝配階段(簡單說,圖元裝配之后,所有 3D 的圖元將被轉(zhuǎn)化為屏幕上 2D 的圖元。)之后被執(zhí)行插值計(jì)算,如果是單重采樣,其插值點(diǎn)為片段的中心,如果多重采樣,其插值點(diǎn)可能為多個(gè)采樣片段中的任意一個(gè)位置。易變變量可以用來保存插值計(jì)算片段的顏色,紋理坐標(biāo)等信息

頂點(diǎn)著色器的輸入輸出模型如下:

<center>{% qnimg 20161012113348032.jpg %}</center>

片元著色器

可編程的片段著色器是實(shí)現(xiàn)一些高級(jí)特效如紋理貼圖,光照,環(huán)境光,陰影等功能的基礎(chǔ)。片段著色器的主要作用是計(jì)算每一個(gè)片段最終的顏色值(或者丟棄該片段)

在片段著色器之前的階段,渲染管線都只是在和頂點(diǎn),圖元打交道。在 3D 圖形程序開發(fā)中,貼圖是最重要的部分,程序可以通過 GL 命令上傳紋理數(shù)據(jù)至 GL 內(nèi)存中,這些紋理可以被片段著色器使用。片段著色器可以根據(jù)頂點(diǎn)著色器輸出的頂點(diǎn)紋理坐標(biāo)對(duì)紋理進(jìn)行采樣,以計(jì)算該片段的顏色值。

另外,片段著色器也是執(zhí)行光照等高級(jí)特效的地方,比如可以傳給片段著色器一個(gè)光源位置和光源顏色,可以根據(jù)一定的公式計(jì)算出一個(gè)新的顏色值,這樣就可以實(shí)現(xiàn)光照特效。

片元著色器的輸入輸出模型如下:

<center>{% qnimg 20161012113417566.jpg %}</center>

著色器語言

著色器語言(Shading Language)是一種高級(jí)的圖形編程語言,僅適合于GPU編程,其源自應(yīng)用廣泛的C語言。對(duì)于頂點(diǎn)著色器和片元著色器的開發(fā)都需要用到著色器語言進(jìn)行開發(fā)。它是面向過程的而非面向?qū)ο蟆jP(guān)于著色器語言也會(huì)放在之后專門的章節(jié)中講述。

坐標(biāo)系

首先說明一點(diǎn),網(wǎng)上很多文章說OpenGL的坐標(biāo)系是右手坐標(biāo)系是不完全正確的,他們所指的是除歸一化設(shè)備坐標(biāo)系(NDC)之外的坐標(biāo)系,OpenGL一共有模型坐標(biāo)系、世界坐標(biāo)系、裁剪坐標(biāo)系、照相機(jī)坐標(biāo)系、規(guī)范化設(shè)備坐標(biāo)系、屏幕坐標(biāo)系等多個(gè)坐標(biāo)系,會(huì)在之后坐標(biāo)系與坐標(biāo)變換的章節(jié)中講述。

這里只需要了解OpenGL中歸一化設(shè)備坐標(biāo)系在不做任何設(shè)置的情況下是左手坐標(biāo)系,其他坐標(biāo)系都是右手坐標(biāo)系,而這些坐標(biāo)系中我們現(xiàn)在所需要知道的就是歸一化設(shè)備坐標(biāo)系(NDC),該坐標(biāo)系經(jīng)過視口變換后就轉(zhuǎn)為屏幕坐標(biāo)系,也就是我們手機(jī)屏幕上的坐標(biāo)系,而我們?cè)O(shè)置的頂點(diǎn)坐標(biāo)其實(shí)是模型坐標(biāo)系下的坐標(biāo),但如果不經(jīng)過任何模型變換、投影變換等操作的話,那么就可以將其視為所設(shè)置的是NDC坐標(biāo)系下的坐標(biāo)。

標(biāo)準(zhǔn)化設(shè)備坐標(biāo)是一個(gè) x、y 和 z 值在 -1.0 到 1.0 的一小段空間。任何落在范圍外的坐標(biāo)都會(huì)被丟棄/裁剪,不會(huì)顯示在你的屏幕上。

投影

OpenGL ES 的世界是3D的,但是手機(jī)屏幕能夠給我展示的終究是一個(gè)平面,只不過是在繪制的過程中利用色彩和線條讓畫面呈現(xiàn)出3D的效果。OpenGL ES將這種從3D到2D的轉(zhuǎn)換過程利用投影的方式使計(jì)算相對(duì)使用者來說變得簡單可設(shè)置。
OpenGL ES中有兩種投影方式:正交投影和透視投影。正交投影,物體不會(huì)隨距離觀測(cè)點(diǎn)的位置而大小發(fā)生變化。而透視投影,距離觀測(cè)點(diǎn)越遠(yuǎn),物體越小,距離觀測(cè)點(diǎn)越近,物體越大。

光柵化

<center>{% qnimg 20170112706113F85123B-6006-4633-9D8C-C4C4DB4BA3AC.png %}</center>

在光柵化階段,基本圖元被轉(zhuǎn)換為供片段著色器使用的片段(Fragment)Fragment 可以簡單理解為能被渲染到屏幕上的像素,它包含位置,顏色,紋理坐標(biāo)等信息,這些值是由圖元的頂點(diǎn)信息進(jìn)行插值計(jì)算得到的。這些片元接著被送到片元著色器中處理。這是從頂點(diǎn)數(shù)據(jù)到可渲染在顯示設(shè)備上的像素的質(zhì)變過程。

在片段著色器運(yùn)行之前會(huì)執(zhí)行裁切(Clipping)。裁切會(huì)丟棄超出你的視圖以外的所有像素,用來提升執(zhí)行效率。

片元在成為像素之前,還會(huì)做多種測(cè)試(比如深度測(cè)試、透明度測(cè)試、模板測(cè)試等,這些測(cè)試目前接觸到的一般在3D圖像中更常使用,比如深度測(cè)試進(jìn)行物體的遮擋效果的渲染,模板測(cè)試可以用于描邊等,2D中應(yīng)用較少)以決定其最終是否會(huì)被顯示為像素。所以,嚴(yán)格來說,“片元”和“像素”并不是一一對(duì)應(yīng)的。

狀態(tài)機(jī)

OpenGL 是一個(gè)狀態(tài)機(jī),它維持自己的狀態(tài),并根據(jù)用戶調(diào)用的函數(shù)來改變自己的狀態(tài)。根據(jù)狀態(tài)的不同,調(diào)用同樣的函數(shù)也可能產(chǎn)生不同的效果。

在 OpenGL 的世界里,大多數(shù)元素都可以用狀態(tài)來描述,比如:

  • 顏色、紋理坐標(biāo)、光源的各種參數(shù)…
  • 是否啟用了光照、是否啟用了紋理、是否啟用了混合、是否啟用了深度測(cè)試
  • 。。。

OpenGL 會(huì)保持狀態(tài),除非我們調(diào)用 OpenGL 函數(shù)來改變它。比如你用glEnableXXX開啟了一個(gè)狀態(tài),在以后的渲染中將一直保留并應(yīng)用這個(gè)狀態(tài),除非你調(diào)用glDisableXXX及同類函數(shù)來改變?cè)摖顟B(tài)或程序退出。

又或者當(dāng)前顏色是一個(gè)狀態(tài)變量,可以把當(dāng)前顏色設(shè)置為白色、紅色或其他任何顏色,在此之后繪制的所有物體都將使用這種顏色,直到把當(dāng)前顏色設(shè)置為其他顏色。

介紹狀態(tài)機(jī)是因?yàn)镺penGL 當(dāng)中很多 API,其實(shí)僅僅是向 OpenGL 這個(gè)狀態(tài)機(jī)傳數(shù)據(jù)或者讀數(shù)據(jù)。而這個(gè)操作在之后的OpenGL操作中非常普遍。

上下文

上面提到的各種狀態(tài)值,將保存在對(duì)應(yīng)的上下文(Context)中。

通過放置這些狀態(tài)到上下文中,上下文可以跟蹤用于渲染的幀緩存、用于幾何數(shù)據(jù)、顏色等的緩存。還會(huì)決定是否使用如紋理、燈光等功能以及會(huì)為渲染定義當(dāng)前的坐標(biāo)系統(tǒng)等。并且在多任務(wù)的情況下,就能很容易的共享硬件設(shè)備,而互不影響各自的狀態(tài)。

因此渲染的時(shí)候,要指定對(duì)應(yīng)的當(dāng)前上下文,也就是在按要求創(chuàng)建一系列諸如EGLSurface、EGLDisplay等對(duì)象,調(diào)用glMakeCurrent之后,當(dāng)前線程便擁有了OpenGL的繪圖能力,而在此之后才能使用OpenGL繪圖等操作,否則會(huì)出錯(cuò)。在GLSurfaceView中我們可以看到一個(gè)GLThread,也就是所謂的GL線程,其實(shí)這個(gè)線程和我們的普通線程沒有區(qū)別,但是其內(nèi)部封裝了OpenGL繪制所需要的整個(gè)完整過程,并且按照這個(gè)流程正確地執(zhí)行,這就是我們所說的具有了OpenGL的繪圖能力。

渲染管線

在 OpenGL 中,任何事物都在 3D 空間中,而屏幕和窗口卻是 2D 像素?cái)?shù)組,這導(dǎo)致 OpenGL 的大部分工作都是關(guān)于把 3D 坐標(biāo)轉(zhuǎn)變?yōu)檫m應(yīng)你屏幕的 2D 像素。3D 坐標(biāo)轉(zhuǎn)為 2D 坐標(biāo)的處理過程是由 OpenGL 的圖形渲染管線(Graphics Pipeline,實(shí)際上指的是一堆原始圖形數(shù)據(jù)途經(jīng)一個(gè)輸送管道,期間經(jīng)過各種變化處理最終出現(xiàn)在屏幕的過程)管理的。圖形渲染管線可以被劃分為兩個(gè)主要部分:第一部分把你的3D 坐標(biāo)轉(zhuǎn)換為 2D 坐標(biāo),第二部分是把 2D 坐標(biāo)轉(zhuǎn)變?yōu)閷?shí)際的有顏色的像素。

2D 坐標(biāo)和像素也是不同的,2D 坐標(biāo)精確表示一個(gè)點(diǎn)在 2D 空間中的位置,而 2D 像素是這個(gè)點(diǎn)的近似值,2D 像素受到你的屏幕/窗口分辨率的限制。

圖形渲染管線可以被劃分為幾個(gè)階段,每個(gè)階段將會(huì)把前一個(gè)階段的輸出作為輸入。所有這些階段都是高度專門化的(它們都有一個(gè)特定的函數(shù)),并且很容易并行執(zhí)行。它的工作過程和車間流水線一致,各個(gè)模塊各司其職但是又相互依賴。
下圖就是渲染管線:

<center>{% qnimg 20170112148420103614414.jpg %}</center>

OpenGL ES 采用服務(wù)器/客戶端編程模型,客戶端運(yùn)行在 CPU 上,服務(wù)端運(yùn)行在 GPU 上,調(diào)用 OpenGL ES 函數(shù)的時(shí),由客戶端發(fā)送至服務(wù)器端,并被服務(wù)端轉(zhuǎn)換成底層圖形硬件支持的繪制命令。
<center>{% qnimg 20170111148411873373682.jpg %}</center>

其他

其它更多的諸如3D模型加載、陰影、粒子、混合與霧、標(biāo)志板、天空盒和與天空穹等內(nèi)容等后面具體應(yīng)用時(shí)再詳細(xì)介紹。

渲染過程

OpenGL ES 2.0的渲染過程可以簡單敘述為:

讀取頂點(diǎn)數(shù)據(jù)——執(zhí)行頂點(diǎn)著色器——組裝圖元——光柵化圖元——執(zhí)行片元著色器——寫入幀緩沖區(qū)——顯示到屏幕上。

OpenGL作為本地庫直接運(yùn)行在硬件上,沒有虛擬機(jī),也沒有垃圾回收或者內(nèi)存壓縮。在Java層定義圖像的數(shù)據(jù)需要能被OpenGL存取,因此,需要把內(nèi)存從Java堆復(fù)制到本地堆

頂點(diǎn)著色器是針對(duì)每個(gè)頂點(diǎn)都會(huì)執(zhí)行的程序,是確定每個(gè)頂點(diǎn)的位置。同理,片元著色器是針對(duì)每個(gè)片元都會(huì)執(zhí)行的程序,確定每個(gè)片元的顏色。

著色器需要進(jìn)行編譯,然后鏈接到OpenGL程序(Program)中。一個(gè)OpenGL的程序就是把一個(gè)頂點(diǎn)著色器和一個(gè)片段著色器鏈接在一起變成單個(gè)對(duì)象。

繪制一個(gè)三角形

正如我們學(xué)習(xí)Java、C++等編程語言時(shí)大多數(shù)教程都會(huì)先告訴你怎么寫出一句Hello World,OpenGL的教程大多數(shù)第一課也是教你如何繪制一個(gè)簡單三角形。接下來我們就按照上述所說的渲染過程,講解一下如何通過OpenGL ES的API在Android手機(jī)上顯示出一個(gè)三角形。

在Demo中我們創(chuàng)建一個(gè)TriangleActivity作為我們的界面,使用Android自帶的GLSurfaceView作為渲染的載體(現(xiàn)在自己創(chuàng)建EGLSurface還為時(shí)過早),同時(shí)我們創(chuàng)建一個(gè)TriangleRenderer作為GLSurfaceView的Renderer,在其里面實(shí)現(xiàn)實(shí)際的渲染操作。為了便于理解,著色器、頂點(diǎn)數(shù)組等將全部放于該Renderer內(nèi),后續(xù)的例子再進(jìn)行封裝。

第一個(gè)Renderer

首先我們現(xiàn)在創(chuàng)建并實(shí)現(xiàn)整個(gè)渲染過程中最核心的部分TriangleRenderer,并讓其實(shí)現(xiàn)Renderer接口。

Renderer接口中有三個(gè)需要實(shí)現(xiàn)的方法,分別是onSurfaceCreatedonSurfaceChanged以及onDrawFrame ,前兩個(gè)方法如果有接觸過SurfaceView及SurfaceHolder的話就比較熟悉,分別是Surface創(chuàng)建時(shí)的回調(diào)以及SUrface如寬高變化時(shí)的回調(diào),onSurfaceCreated主要用于初始化等,onSurfaceChanged主要用于做模型視圖轉(zhuǎn)換等操作,而onDrawFrame就是當(dāng)OpenGL渲染每一幀的回調(diào)方法,我們的實(shí)際繪制操作就在這里進(jìn)行。

這三個(gè)方法我們先放著,先來按照渲染流程,我們創(chuàng)建繪制一個(gè)三角形所需要的頂點(diǎn)數(shù)據(jù)
頂點(diǎn)數(shù)據(jù)是一個(gè)包含了所繪制圖像放置在OpenGL坐標(biāo)系中后,其各個(gè)頂點(diǎn)的三維坐標(biāo)的數(shù)組(其實(shí)頂點(diǎn)數(shù)據(jù)還可以放置顏色等,通過偏移來獲取不同類型的數(shù)據(jù))。那么剛剛在坐標(biāo)系中說了,OpenGL里有多個(gè)坐標(biāo)系,但是和我們目前關(guān)系最大的是NDC,NDC坐標(biāo)系:

<center>{% qnimg CFB95B14-3302-49B6-A16E-96AAB5C0DCC5.png %}</center>

即NDC坐標(biāo)系的原點(diǎn)(0,0)默認(rèn)位置在屏幕中心,x,y,z軸范圍為[-1,1],而Android屏幕坐標(biāo)系原點(diǎn)在左上角,x,y軸范圍為[0,各軸分辨率]。

現(xiàn)在我們要繪制一個(gè)三角形,頂點(diǎn)在y軸正向最大值位置,左下角在x軸負(fù)向最大值位置,右下角在x軸正向最大值位置,那么對(duì)應(yīng)的頂點(diǎn)數(shù)組為:
<pre><code>
//設(shè)置三角形頂點(diǎn)數(shù)組
private static final float TRIANGLE_COORDS[] = {
//默認(rèn)按逆時(shí)針方向繪制??
0.0f, 1.0f, 0.0f, // 頂點(diǎn)
-1.0f, -0.0f, 0.0f, // 左下角
1.0f, -0.0f, 0.0f // 右下角
};
</code></pre>
接下來我們開始編寫頂點(diǎn)著色器:
<pre><code>
private static final String VERTEX_SHADER =
"http://根據(jù)所設(shè)置的頂點(diǎn)數(shù)據(jù)而插值后的頂點(diǎn)坐標(biāo)\n" +
"attribute vec4 vPosition;" +
"void main() {" +
" //設(shè)置最終坐標(biāo)\n"
" gl_Position = vPosition;" +
"}";
</code></pre>
對(duì)于上述著色器只需要知道vPosition就是我們所設(shè)置的頂點(diǎn)數(shù)據(jù)進(jìn)行自動(dòng)插值后的當(dāng)前片元的頂點(diǎn)坐標(biāo),而gl_Position是OpenGL的內(nèi)置變量,代表著當(dāng)前這個(gè)片元最終所處的坐標(biāo)。而vec是代表向量,坐標(biāo)使用vec4而不是vec3的原因是因?yàn)?code>齊次坐標(biāo)的關(guān)系,但是這個(gè)在這里不是重點(diǎn)。

組裝圖光柵化圖元OpenGL會(huì)自動(dòng)進(jìn)行,這里我們不管,接下來我們開始編寫片元著色器,來為這個(gè)三角形加上顏色:
<pre><code>
private static final String FRAGMENT_SHADER =
"http://設(shè)置float類型默認(rèn)精度,頂點(diǎn)著色器默認(rèn)highp,片元著色器需要用戶聲明\n" +
"precision mediump float;" +
"http://顏色值,vec4代表四維向量,此處由用戶傳入,數(shù)據(jù)格式為{r,g,b,a}\n" +
"uniform vec4 vColor;" +
"void main() {" +
"http://該片元最終顏色值\n" +
"gl_FragColor = vColor;" +
"}";
</code></pre>
在上述著色器代碼中,首先我們聲明了片元著色器中默認(rèn)float類型變量的精度(中等),在頂點(diǎn)著色器中默認(rèn)精度為highp,而片元著色器中必須自己設(shè)置。

之后我們聲明了一個(gè)uniform類型的四維向量,用以存儲(chǔ)用戶所設(shè)置的三角形顏色(當(dāng)然這里顏色的值也是用戶所設(shè)置的顏色數(shù)據(jù)進(jìn)行逐片元插值后的值),gl_FragColor也是OpenGL的內(nèi)置變量,表示片元最終的顏色值,這里我們直接將插值后的vColor作為最終顏色。

從片元著色器中可以看到,有一個(gè)數(shù)據(jù)還需要用戶自己設(shè)定,那就是三角形的顏色值,格式是{r,g,b,a},設(shè)置如下:
<pre><code>
// 設(shè)置三角形顏色和透明度(r,g,b,a)
private static final float COLOR[] = {1.0f, 0.0f, 0f, 1.0f};//紅色不透明
</code></pre>
最后寫入幀緩沖區(qū)顯示到屏幕上也是由OpenGL自動(dòng)完成,那么至此我們已經(jīng)完成了頂點(diǎn)著色器和片元著色器的實(shí)現(xiàn),也提供了這兩個(gè)著色器所需要的頂點(diǎn)數(shù)據(jù)和顏色數(shù)據(jù),那么接下來就是怎么將這些數(shù)據(jù)與著色器內(nèi)的變量相綁定,并且告知OpenGL什么時(shí)候開始渲染以及怎么渲染。

讓我們回到Renderer那三個(gè)未實(shí)現(xiàn)的接口上,首先我們?cè)?code>onSurfaceCreated調(diào)用時(shí),也就是Surface正式創(chuàng)建后,做一些初始化操作:
<pre><code>
private int mProgramId;
private int mColorId;
private int mPositionId;
@Override
public void onSurfaceCreated(GL10 gl10, EGLConfig eglConfig) {
//編譯著色器并鏈接頂點(diǎn)與片元著色器生成OpenGL程序句柄
mProgramId = OpenGlUtils.loadProgram(VERTEX_SHADER, FRAGMENT_SHADER);
//通過OpenGL程序句柄查找獲取頂點(diǎn)著色器中的位置句柄
mPositionId = GLES20.glGetAttribLocation(mProgramId, "vPosition");
//通過OpenGL程序句柄查找獲取片元著色器中的顏色句柄
mColorId = GLES20.glGetUniformLocation(mProgramId, "vColor");
}
</code></pre>
上述代碼中我們借助了一個(gè)封裝了編譯著色器并鏈接生成當(dāng)前OpenGL程序句柄的工具類,我們先來簡單介紹一下OpenGL中編譯著色器并鏈接至最終的Program上的流程:
<pre><code>
public static int loadShader(final String strSource, final int iType) {
int[] compiled = new int[1];
//創(chuàng)建指定類型的著色器
int iShader = GLES20.glCreateShader(iType);
//將源碼添加到iShader并編譯它
GLES20.glShaderSource(iShader, strSource);
GLES20.glCompileShader(iShader);
//獲取編譯后著色器句柄存在在compiled數(shù)組容器中
GLES20.glGetShaderiv(iShader, GLES20.GL_COMPILE_STATUS, compiled, 0);
//容錯(cuò)判斷
if (compiled[0] == 0) {
Log.d("Load Shader Failed", "Compilation\n" + GLES20.glGetShaderInfoLog(iShader));
return 0;
}
return iShader;
}

public static int loadProgram(final String strVSource, final String strFSource) {
int iVShader;
int iFShader;
int iProgId;
int[] link = new int[1];
//獲取編譯后的頂點(diǎn)著色器句柄
iVShader = loadShader(strVSource, GLES20.GL_VERTEX_SHADER);
if (iVShader == 0) {
Log.d("Load Program", "Vertex Shader Failed");
return 0;
}
//獲取編譯后的片元著色器句柄
iFShader = loadShader(strFSource, GLES20.GL_FRAGMENT_SHADER);
if (iFShader == 0) {
Log.d("Load Program", "Fragment Shader Failed");
return 0;
}
//創(chuàng)建一個(gè)Program
iProgId = GLES20.glCreateProgram();
//添加頂點(diǎn)著色器與片元著色器到Program中
GLES20.glAttachShader(iProgId, iVShader);
GLES20.glAttachShader(iProgId, iFShader);
//鏈接生成可執(zhí)行的Program
GLES20.glLinkProgram(iProgId);
//獲取Program句柄,并存在在link數(shù)組容器中
GLES20.glGetProgramiv(iProgId, GLES20.GL_LINK_STATUS, link, 0);
//容錯(cuò)
if (link[0] <= 0) {
Log.d("Load Program", "Linking Failed");
return 0;
}
//刪除已鏈接后的著色器
GLES20.glDeleteShader(iVShader);
GLES20.glDeleteShader(iFShader);
return iProgId;
}
</code></pre>
上述代碼中關(guān)鍵代碼點(diǎn)都有注釋,這里可以發(fā)現(xiàn)OpenGL的很多接口調(diào)用方式與C非常相似,比如獲取句柄的方式是將句柄存入一個(gè)數(shù)組容器中,與C的指針有點(diǎn)相像。

到這里我們已經(jīng)獲取到了一個(gè)Program,還有后續(xù)需要綁定我們數(shù)據(jù)的vColor,vPosition的地址。接下來我們需要設(shè)置視口來告訴OpenGL我們想要顯示在屏幕的哪個(gè)區(qū)域內(nèi):
<pre><code>
@Override
public void onSurfaceChanged(GL10 gl10, int width, int height) {
GLES20.glViewport(0, 0, width, height);
}
</code></pre>
當(dāng)我們?cè)O(shè)置GLSurfaceView為全屏的時(shí)候,那么上述的width就是屏幕寬度,height就是屏幕高度,上述設(shè)置的意思就是我們當(dāng)前渲染的視口區(qū)域從屏幕左上角原點(diǎn)(0,0)開始,寬高為全屏。

至此就萬事俱備了,接下來我們便要在OpenGL開始渲染的回調(diào)接口onDrawFrame()中進(jìn)行我們最后的渲染操作了:
<pre><code>
//設(shè)置每個(gè)頂點(diǎn)的坐標(biāo)數(shù)
private static final int COORDS_PER_VERTEX = 3;
//下一個(gè)頂點(diǎn)與上一個(gè)頂點(diǎn)之間的不長,以字節(jié)為單位,每個(gè)float類型變量為4字節(jié)
private final int VERTEX_STRID = COORDS_PER_VERTEX * 4;
//頂點(diǎn)個(gè)數(shù)
private final int VERTEX_COUNT = TRIANGLE_COORDS.length / COORDS_PER_VERTEX;

@Override
public void onDrawFrame(GL10 gl10) {
//這里網(wǎng)上很多博客說是設(shè)置背景色,其實(shí)更嚴(yán)格來說是通過所設(shè)置的顏色來清空顏色緩沖區(qū),改變背景色只是其作用之一
GLES20.glClearColor(1.0f, 1.0f, 1.0f, 1.0f);//白色不透明
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT);
//告知OpenGL所要使用的Program
GLES20.glUseProgram(mProgramId);
//啟用指向三角形頂點(diǎn)數(shù)據(jù)的句柄
GLES20.glEnableVertexAttribArray(mPositionId);
//綁定三角形的坐標(biāo)數(shù)據(jù)
GLES20.glVertexAttribPointer(mPositionId, COORDS_PER_VERTEX,
GLES20.GL_FLOAT, false,
VERTEX_STRID, mVertexBuffer);
//綁定顏色數(shù)據(jù)
GLES20.glUniform4fv(mColorId, 1, TRIANGLE_COORDS, 0);
//繪制三角形
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, VERTEX_COUNT);
//禁用指向三角形的頂點(diǎn)數(shù)據(jù)
GLES20.glDisableVertexAttribArray(mPositionId);
}
</code></pre>
首先這里我們注意到除了注釋之外,我們的代碼少了一個(gè)變量,就是mVertexBuffer,這個(gè)變量是一個(gè)FloatBuffer類型的變量,用于開辟處一塊內(nèi)存緩沖區(qū)來存儲(chǔ)供OpenGL使用的頂點(diǎn)數(shù)據(jù),在我們這個(gè)Demo中頂點(diǎn)數(shù)據(jù)不會(huì)發(fā)生變化,所以我們直接在onSurfaceCreated()接口的最后加上如下代碼進(jìn)行初始化即可:
<pre><code>
//初始化頂點(diǎn)字節(jié)緩沖區(qū),用于存放三角的頂點(diǎn)數(shù)據(jù)
ByteBuffer bb = ByteBuffer.allocateDirect(
//(每個(gè)浮點(diǎn)數(shù)占用4個(gè)字節(jié)
TRIANGLE_COORDS.length * 4);
//設(shè)置使用設(shè)備硬件的原生字節(jié)序
bb.order(ByteOrder.nativeOrder());
//從ByteBuffer中創(chuàng)建一個(gè)浮點(diǎn)緩沖區(qū)
mVertexBuffer = bb.asFloatBuffer();
//把坐標(biāo)都添加到FloatBuffer中
mVertexBuffer.put(TRIANGLE_COORDS);
//設(shè)置buffer從第一個(gè)位置開始讀
//因?yàn)樵诿看握{(diào)用put加入數(shù)據(jù)后position都會(huì)加1,因此要將position重置為0
mVertexBuffer.position(0);
</code></pre>
還有記得在接口外面聲明變量:
<pre><code>
private FloatBuffer vertexBuffer;
</code></pre>
為什么使用java的nio包下的Buffer作為內(nèi)存緩沖區(qū)的形式一方面是出于性能等方面的考慮,另一方面 OpenGL 是一個(gè)非常底層的繪制接口,它所使用的緩沖區(qū)存儲(chǔ)結(jié)構(gòu)是和我們的 Java 程序中不相同的(Java 是大端字節(jié)序(BigEdian),而 OpenGL 所需要的數(shù)據(jù)是小端字節(jié)序(LittleEdian))。所以,我們?cè)趯?Java 的緩沖區(qū)轉(zhuǎn)化為 OpenGL 可用的緩沖區(qū)時(shí)需要作這樣的一些工作。

而顏色的綁定我們看到就簡單得多,只需要調(diào)用接口就可以實(shí)現(xiàn),因?yàn)閮烧叩脑谶@個(gè)Demo中變量類型不同(attribute只能在頂點(diǎn)著色器中使用,通常用于表示頂點(diǎn)坐標(biāo)、紋理坐標(biāo)等,而uniform常用于表示常量形式的顏色、矩陣、材質(zhì)等,兩者設(shè)置接口也不同,具體會(huì)在后續(xù)著色器章節(jié)中講述)。我們也可以通過將顏色與頂點(diǎn)數(shù)據(jù)放置一起,然后一起轉(zhuǎn)為FloatBuffer來傳遞給OpenGL,并且設(shè)置每個(gè)頂點(diǎn)的顏色不同,通過glVertexPointerglColorPointer兩個(gè)接口配合使用來繪制出如下的三角形,這也就是之前一直講的插值的含義,OpenGL會(huì)自動(dòng)對(duì)頂點(diǎn)間坐標(biāo)以及顏色進(jìn)行插值計(jì)算:

<center>{% qnimg 1363184395_2222.png %}</center>

至此我們已經(jīng)完成TriangleRender的實(shí)現(xiàn),最后只需要加上一個(gè)回收資源的方法:
<pre><code>
public void destroy() {
GLES20.glDeleteProgram(mProgramId);
}
</code></pre>

GLSurfaceView

前面我們完成了TriangleRender的實(shí)現(xiàn),那么接下來我們將其與GLSurfaceView綁定起來以便于看到我們渲染的結(jié)果。

為了簡單起見,我們直接TriangleActivity的布局文件中加入GLSurfaceView,在onCreate()中加入如下代碼:
<pre><code>
GLSurfaceView glSurfaceView = findViewById(R.id.gl_surface);
// 創(chuàng)建OpenGL ES 2.0的上下文
glSurfaceView.setEGLContextClientVersion(2);
//設(shè)置Renderer用于繪圖
glSurfaceView.setRenderer(new TriangleRender());
//只有在繪制數(shù)據(jù)改變時(shí)才繪制view,可以防止GLSurfaceView幀重繪
//該種模式下當(dāng)需要重繪時(shí)需要我們手動(dòng)調(diào)用glSurfaceView.requestRender();
glSurfaceView.setRenderMode(GLSurfaceView.RENDERMODE_WHEN_DIRTY);
</code></pre>
最后我們跑一下項(xiàng)目,就可以在手機(jī)上看到如下的三角形了:

<center>{% qnimg device-2018-03-18-15033.png %}</center>

總結(jié)

這章我們介紹了OpenGL(ES)以及EGL的相關(guān)內(nèi)容和一些基本概念,同時(shí)通過繪制一個(gè)簡單的三角形來了解了OpenGL的常見繪制流程以及部分接口,感興趣的可以自己嘗試一下如何繪制一個(gè)漸變的三角形或者一個(gè)正方形等較簡單的幾何圖形。使用OpenGL進(jìn)行繪制的確比直接使用Android自帶的繪圖API繁瑣一些,出現(xiàn)了問題也比較難以排查,因?yàn)楦咏诘讓铀岳斫馍虾芏嗟胤讲惶粯樱菍?duì)于圖形渲染或者處理,OpenGL無論是性能還是可以實(shí)現(xiàn)的效果都是勝出一籌的。

下一章將深入講解一下紋理的相關(guān)內(nèi)容,同時(shí)繪制一個(gè)簡單的紋理。該系列教程Demo見OpenGLESLesson

參考

OpenGL ES 基礎(chǔ)概念
OpenGL ES 開篇
卡通渲染(上)
Android 系統(tǒng)圖形棧(一): OpenGL ES 和 EGL 介紹
Android OpenGLES2.0(一)——了解OpenGLES2.0

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

推薦閱讀更多精彩內(nèi)容