開始探索奇妙的 3D 世界了,OpenGL 搞起。
OpenGL 簡介
OpenGL 是一種應用程式程式設計介面,它是一種可以對圖形硬體裝置特性進行訪問的軟體庫。
重點:OpenGL 是一種介面,既然是介面,那麼就必然要有實現。
事實上,它的實現是由顯示裝置廠商提供的,而且依賴於廠商提供的硬體裝置。
OpenGL 常用於 CAD、虛擬實境、科學視覺化程式和電子遊戲開發。
在 Android 上使用的是 OpenGL ES,它是 OpenGL 的子集,在 OpenGL 的基礎之上裁剪掉了一些非必要的部分,主要是針對手機、PAD 和遊戲主機等嵌入式裝置設計的。
在 Android 上開發 OpenGL 既可以使用 Java 也可以使用 C ,話不多說,擼起袖子就是幹!
OpenGL 的繪製流程
學習 OpenGL 的繪製,最好還是先從 2D 繪製開始,逐漸過渡到 3D 繪製。
Android 為 OpenGL 的繪製提供了一個特定的檢視GLSurfaceView
,就像 SurfaceView 一樣,它渲染繪製也可以在一個單獨的執行緒中,而非主執行緒,畢竟 GLSurfaceView 就是繼承自 SurfaceView 的。
在使用 GLSurfaceView 時,需要通過setRenderer
方法給它設定一個渲染器,而主要的渲染工作就是由渲染器Renderer
完成了。
通過繼承GLSurfaceView.Renderer
類來實現我們自己的渲染器程式,主要有如下三個方法:
- onSurfaceCreated
- 當 GLSurfaceView 建立時呼叫,主要做一些準備工作。
- onSurfaceChanged
- 當 GLSurfaceView 檢視改變時呼叫,第一次建立時也會被呼叫。
- onDrawFrame
- 每一幀繪製時被呼叫。
實現渲染器程式時,首先要考慮三個問題:
- 在什麼地方進行繪製?
- 繪製成什麼形狀?
- 用什麼顏色來繪製?
而我們的程式也主要以解決上述三個問題為主,下面以 OpenGL 繪製一個點來講解。
OpenGL 座標
手機螢幕的座標是以左上角為原點(0,0),向右為 X 軸正方形,向下為 Y 軸正方向,而 OpenGL 也有著它自己的一套座標定義。
假設我們定義了一個點的座標(4.3,2.1),也就是它的 X 軸座標和 Y 軸座標,而 OpenGL 最後會把我們定義的座標對映手機螢幕的實際物理座標上。
無論是 X 座標還是 Y 座標,OpenGL 都會把手機螢幕對映到 [-1,1] 的範圍內。也就是說:螢幕的左邊對應 X 軸的 -1 ,螢幕的右邊對應 +1,螢幕的底邊會對應 Y 軸的 -1,而螢幕的頂邊就對應 +1。
不管螢幕是什麼形狀和大小,這個座標範圍都是一樣的,例如下圖所示:
所以,上面定義的座標(4.3,2.1),最後是會被對映到手機螢幕之外的,處於不可見的狀態。
這裡,假定繪製一個位於原點的點(0,0),那麼對映之後的位置就手機螢幕的中心了。
基本圖元
解決了位置的問題,接下來就是形狀和顏色的問題。
如同 Android 的 Canvas 物件提供了一些方法來完成基本的繪製:drawPoint、drawRect、drawLine 等,OpenGL 程式也提供且僅提供了三種基本的圖元來完成繪製。
- 點
- 線
- 三角形
其他的所有形狀都是基於這三種圖元來完成的,比如矩形就可以看成是兩個三角形拼成的。
由於我們要繪製的是一個點,在座標系中,一個座標就可以代替一個點了。假設要繪製一個三角形,那麼在座標系中就需要三個點才行了。
接下來就涉及到 OpenGL 如何把定義的點的資料繪製出來了。
渲染管線
首先要明白一個概念渲染管線
。
根據百度百科的定義,渲染管線也稱為渲染流水線
或畫素流水線
或畫素管線
,是顯示晶片內部(GPU)處理圖形訊號相互獨立的並行處理單元。
顯示卡的渲染管線就是顯示核心的重要組成部分,是負責給圖形配上顏色的一組專門通道。渲染管線的數量是決定顯示晶片效能和檔次的最重要的引數之一。
現階段的顯示卡都是分為頂點渲染
和畫素渲染
的。在顯示卡,內部分為兩大區域,一個區域是頂點渲染單元(也叫頂點著色),主要負責描繪圖形,也就是建立模型。一個區域是畫素渲染管線,主要負責把頂點繪出的圖形填上顏色。
上圖就是 OpenGL 中渲染管線的一個處理流程。
可以看到,流程圖從讀取頂點資料開始,然後後執行兩個著色器:
- 頂點著色器
- 主要負責描繪圖形,也就是根據頂點座標,建立圖形模型。
- 片段著色器
- 主要負責把頂點繪出的圖形填上顏色。
由於這兩個著色器對於最後圖形顯示效果至關重要,並且它們還是可以通過程式設計來控制的,這也是為什麼可程式設計渲染管線
要優於固定程式設計管線了。
事實上,隨著顯示技術的發展,渲染管線將不復存在了,頂點著色器和渲染管線統一被流處理器(Stream Processors)
所取代。
但是目前手機上 OpenGL 還是使用渲染管線中,有了渲染管線,我們就可以完成點的形狀繪製和著色兩大問題了,接下來的工作也是圍繞這條渲染管線開始的。
記憶體拷貝
當定義完了頂點座標,並且明確了下一步:頂點座標將要通過渲染管線進行一系列處理,那麼接下來就是如何把頂點座標傳遞給渲染管線了。
OpenGL 的實現是由顯示裝置廠商提供的,它作為本地系統庫直接執行在硬體上。而我們定義的頂點 Java 程式碼是執行在虛擬機器上的,這就涉及到了如何把 Java 層的記憶體複製到 Native 層了。
一種方法是直接使用JNI
開發,直接呼叫本地系統庫,也就是用 C++
來開發 OpenGL,這種實現肯定要學會的。
另一種方法就是在 Java 層把記憶體塊複製到 Native 層。
使用ByteBuffer.allocateDirect()
方法就可以分配一塊 Native 記憶體,這塊記憶體不會被 Java 的垃圾回收器管理。
它的使用方法大致都一樣,抽出公共的模板:
// 宣告一個位元組緩衝區 FloatBuffer
private FloatBuffer floatBuffer;
// 定義頂點資料
float[] vertexData = new float[16];
// FloatBuffer 初始化工作並放入頂點資料
floatBuffer = ByteBuffer
.allocateDirect(vertexData.length * Constant.BYTES_PRE_FLOAT)
.order(ByteOrder.nativeOrder())
.asFloatBuffer()
.put(vertexData);
複製程式碼
在allocateDirect
方法分配了記憶體並指定了大小之後,下一步就是告訴 ByteBuffer 按照本地位元組序
組織它的內容。本地位元組序是指,當一個值佔用多個位元組時,比如 32 位整型數,位元組按照從最重要位到最不重要位或者相反順序排列。
接下來asFloatBuffer
方法可以得到一個反映底層位元組的 FloatBuffer 類例項,避免直接操作單獨的位元組,而是使用浮點數。
最後,通過put
方法就可以把資料從 Java 層記憶體複製到 Native 層了,當程式結束時,這塊記憶體就會被釋放掉。
頂點著色器
接下來可程式設計的部分了,定義著色器(Shader
)程式。
使用不同的著色器對輸入的圖後設資料執行計算操作,判斷它們的位置、顏色,以及其他渲染屬性。
首先是頂點著色器。
在渲染管線中傳輸的每個頂點座標位置,OpenGL 都會呼叫一個頂點著色器來處理頂點相關的資料,這個處理過程可以很複雜,也可以很簡單。
想要定義一個著色器程式,還要通過一種特殊的語言去編寫:OpenGL Shading Language
,簡稱GLSL
.
GLSL
語言類似於 C 語言或者 Java 語言,它的程式入口也是一個名為main
的函式。關於 GLSL 的部分,完全可以單獨寫一篇部落格了,暫時先不詳細闡述。
下面就是一個簡單的頂點著色器程式:
attribute vec4 a_Position;
void main()
{
gl_Position = a_Position;
gl_PointSize = 30.0;
}
複製程式碼
著色器類似於一個函式呼叫的方式——資料傳輸進來,經過處理,然後再傳輸出去。
其中,gl_Position
和gl_PointSize
就是著色器中的特殊全域性變數,它接收輸入。
a_Position
就是我們定義的一個變數,它是vec4
型別的。而attribute
只能存在於頂點著色器中,一般用於儲存頂點資料,它可以在資料緩衝區中讀取資料。
資料快取區中的頂點座標會賦值給 a_Position ,a_Position 會傳遞給 gl_Position。
而 gl_PointSize 則固定了點的大小為 30。
有了頂點著色器,就能夠為每個頂點生成最終的位置,接下來就是定義片段著色器。
根據上圖的渲染管線,頂點著色器到片段著色器之間,還要經過組裝圖元
和光柵化圖元
。
光柵化技術
移動裝置的螢幕由成百上千個小的、獨立的部件組成,他們稱為畫素
。每個畫素通常由三個單獨的子元件構成,它們發出紅色、綠色和藍色的光,因為每個畫素都非常小,人的眼睛會把紅色、綠色和藍色的光混合在一起,從而創造出巨量的顏色範圍。
OpenGL 就是通過 光柵化 技術的過程把每個點、直線及三角形分解成大量的小片段,它們可以對映到移動裝置螢幕的畫素上,從而生成一幅影像。這些片段類似於螢幕上的畫素,每一個都包含單一的純色。
如下圖所示:
OpenGL 通過光柵化技術把一條直線對映為一個片段集合,顯示系統通常會把這些片段直接對映到螢幕上的畫素,結果一個片段就對應一個畫素。
明白了這樣的顯示原理,就可以在其中做一些操作了,這就是片段著色器的功能了。
片段著色器
片段著色器的主要目的就是告訴 GPU 每個片段的最終顏色應該是什麼。
對於基本圖元的每個片段,片段著色器都會被呼叫一次,因此,如果一個三角形被對映到 10000 個片段,那麼片段著色器就會被呼叫 10000 次。
下面就是一個簡單的片段著色器程式:
precision mediump float;
uniform vec4 u_Color;
void main()
{
gl_FragColor = u_Color;
}
複製程式碼
其中,gl_FragColor
變數就是 OpenGL 最終渲染出來的顏色的全域性變數,而u_Color
就是我們定義的變數,通過在 Java 層繫結到 u_Color
變數並給它賦值,就會傳遞到 Native 層的gl_FragColor
中。
而第一行的mediump
指的就是片段著色器的精度了,有三種可選,這裡用中等精度就行了。uniform
則表示該變數是不可變的了,也就是固定顏色了,目前顯示固定顏色就好了。
編譯 OpenGL 程式
明白了著色器的功能和光柵化技術之後,對渲染管線的流程也就更加清楚了,接下來就是編譯 OpenGL 的程式了。
編譯 OpenGL 程式基本流程如下:
- 編譯著色器
- 建立 OpenGL 程式和著色器連結
- 驗證 OpenGL 程式
- 確定使用 OpenGL 程式
編譯著色器
建立新的檔案編寫著色器程式,然後再從檔案以字串的形式中讀取檔案內容。這樣會比把著色器程式寫成字串的形式更加清晰。
當讀取了著色器程式內容之後,就可以編譯了。
// 編譯頂點著色器
public static int compileVertexShader(String shaderCode) {
return compileShader(GL_VERTEX_SHADER, shaderCode);
}
// 編譯片段著色器
public static int compleFragmentShader(String shaderCode) {
return compileShader(GL_FRAGMENT_SHADER, shaderCode);
}
// 根據型別編譯著色器
private static int compileShader(int type, String shaderCode) {
// 根據不同的型別建立著色器 ID
final int shaderObjectId = glCreateShader(type);
if (shaderObjectId == 0) {
return 0;
}
// 將著色器 ID 和著色器程式內容連線
glShaderSource(shaderObjectId, shaderCode);
// 編譯著色器
glCompileShader(shaderObjectId);
// 以下為驗證編譯結果是否失敗
final int[] compileStatsu = new int[1];
glGetShaderiv(shaderObjectId, GL_COMPILE_STATUS, compileStatsu, 0);
if ((compileStatsu[0] == 0)) {
// 失敗則刪除
glDeleteShader(shaderObjectId);
return 0;
}
return shaderObjectId;
}
複製程式碼
以上程式主要就是通過glCreateShader
方法建立了著色器 ID,然後通過glShaderSource
連線上著色器程式內容,接下來通過glCompileShader
編譯著色器,最後通過glGetShaderiv
驗證是否失敗。
glGetShaderiv
函式比較通用,在著色器階段和 OpenGL 程式階段都會通過它來驗證結果。
建立 OpenGL 程式和著色器連結
接下來就是建立 OpenGL 程式並加著色器加進來。
public static int linkProgram(int vertexShaderId, int fragmentShaderId) {
// 建立 OpenGL 程式 ID
final int programObjectId = glCreateProgram();
if (programObjectId == 0) {
return 0;
}
// 連結上 頂點著色器
glAttachShader(programObjectId, vertexShaderId);
// 連結上 片段著色器
glAttachShader(programObjectId, fragmentShaderId);
// 連結著色器之後,連結 OpenGL 程式
glLinkProgram(programObjectId);
final int[] linkStatus = new int[1];
// 驗證連結結果是否失敗
glGetProgramiv(programObjectId, GL_LINK_STATUS, linkStatus, 0);
if (linkStatus[0] == 0) {
// 失敗則刪除 OpenGL 程式
glDeleteProgram(programObjectId);
return 0;
}
return programObjectId;
}
複製程式碼
首先通過glCreateProgram
程式建立 OpenGL 程式,然後通過glAttachShader
將著色器程式 ID 新增上 OpenGL 程式,接下來通過glLinkProgram
連結 OpenGL 程式,最後通過glGetProgramiv
來驗證連結是否失敗。
驗證 OpenGL 程式
連結了 OpenGL 程式後,就是驗證 OpenGL 是否可用。
public static boolean validateProgram(int programObjectId) {
glValidateProgram(programObjectId);
final int[] validateStatus = new int[1];
glGetProgramiv(programObjectId, GL_VALIDATE_STATUS, validateStatus, 0);
return validateStatus[0] != 0;
}
複製程式碼
通過glValidateProgram
函式驗證,並再次通過glGetProgramiv
函式驗證是否失敗。
確定使用 OpenGL 程式
當一切完成後,就是確定使用該 OpenGL 程式了。
// 建立 OpenGL 程式過程
public static int buildProgram(Context context, int vertexShaderSource, int fragmentShaderSource) {
int program;
int vertexShader = compileVertexShader(
TextResourceReader.readTextFileFromResource(context, vertexShaderSource));
int fragmentShader = compleFragmentShader(
TextResourceReader.readTextFileFromResource(context, fragmentShaderSource));
program = linkProgram(vertexShader, fragmentShader);
validateProgram(program);
return program;
}
// 建立完畢後,確定使用
mProgram = ShaderHelper.buildProgram(context, R.raw.point_vertex_shader
, R.raw.point_fragment_shader);
glUseProgram(mProgram);
複製程式碼
將上述的過程合併起來,buildProgram
函式返回的 OpenGL 程式 ID 即可,通過glUseProgram
函式表示使用該 OpenGL 程式。
繪製
完成了 OpenGL 程式的編譯,就是最後的繪製了,再回到渲染器 Renderer
裡面。
public class PointRenderer extends BaseRenderer {
private Point mPoint;
public PointRenderer(Context mContext) {
super(mContext);
}
@Override
public void onSurfaceCreated(GL10 gl, EGLConfig config) {
super.onSurfaceCreated(gl, config);
glClearColor(0.0f, 0.0f, 0.0f, 0.0f);
// 在 onSurfaceCreated 裡面初始化,否則會報執行緒錯誤
mPoint = new Point(mContext);
// 繫結相應的頂點資料
mPoint.bindData();
}
@Override
public void onSurfaceChanged(GL10 gl, int width, int height) {
// 確定視口大小
glViewport(0, 0, width, height);
}
@Override
public void onDrawFrame(GL10 gl) {
// 清屏
glClear(GL_COLOR_BUFFER_BIT);
// 繪製
mPoint.draw();
}
}
複製程式碼
在onSurfaceCreated
函式裡面做初始化工作,繫結資料等;在onSurfaceChanged
方法裡面確定檢視大小,在onDrawFrame
裡面執行繪製。
為了簡化渲染流程,把所有的操作都放在放在要渲染的物件裡面去了,宣告一個 Point 物件,代表要繪製的點。
public class Point extends BaseShape {
// 著色器中定義的變數,在 Java 層繫結並賦值
private static final String U_COLOR = "u_Color";
private static final String A_POSITION = "a_Position";
private int aColorLocation;
private int aPositionLocation;
float[] pointVertex = {
0f, 0f
};
public Point(Context context) {
super(context);
mProgram = ShaderHelper.buildProgram(context, R.raw.point_vertex_shader
, R.raw.point_fragment_shader);
glUseProgram(mProgram);
vertexArray = new VertexArray(pointVertex);
POSITION_COMPONENT_COUNT = 2;
}
@Override
public void bindData() {
//繫結值
aColorLocation = glGetUniformLocation(mProgram, U_COLOR);
aPositionLocation = glGetAttribLocation(mProgram, A_POSITION);
// 給繫結的值賦值,也就是從頂點資料那裡開始讀取,每次讀取間隔是多少
vertexArray.setVertexAttribPointer(0, aPositionLocation, POSITION_COMPONENT_COUNT,
0);
}
@Override
public void draw() {
// 給繫結的值賦值
glUniform4f(aColorLocation, 0.0f, 0.0f, 1.0f, 1.0f);
glDrawArrays(GL_POINTS, 0, 1);
}
}
複製程式碼
在 Point 的建構函式中,編譯並使用 OpenGL 程式,而在 bindData
函式中,通過glGetUniformLocation
和glGetAttribLocation
函式繫結了我們在 OpenGL 中宣告的變數u_Color
和a_Position
,要注意的是,attribute
型別和uniform
型別所對應的方法是不同的,最後通過給POSITION_COMPONENT_COUNT
變數賦值,指定每個頂點資料的個數為 2 。
繫結了變數之後,接下來就是給他們賦值了,對於uniform
型別變數,由於是固定值,所以直接呼叫glUniform4f
方法給其賦值就好了,而attribute
型別變數,則需要對應頂點資料中的值了,vertexArray.setVertexAttribPointer
方法就是完成這個任務的。
// 給某個頂點資料繫結值,並 Enable 使能
public void setVertexAttribPointer(int dataOffset, int attributeLocation, int componentCount, int stride) {
floatBuffer.position(dataOffset);
glVertexAttribPointer(attributeLocation, componentCount, GL_FLOAT, false, stride, floatBuffer);
glEnableVertexAttribArray(attributeLocation);
floatBuffer.position(0);
}
複製程式碼
在setVertexAttribPointer
方法中,使用了glVertexAttribPointer
方法來繫結值,它的引數釋義如下所示:
通過glEnableVertexAttribArray
方法來開啟使用即可。
最後通過glDrawArrays
方法來執行最後的繪製,GL_POINTS
代表繪製的型別,而引數0,1
則代表繪製的點的範圍,它是一個左閉右開的區間。
以上步驟就完成了一個點的繪製,如圖所示:
具體程式碼詳情,可以參考我的 Github 專案:
小結
使用 OpenGL 進行繪製的原理,也就是按照 GPU 的渲染管線流程,提供了頂點資料之後,執行頂點著色器,然後執行片段著色器,最後對映到手機螢幕上。
而作為可程式設計的階段,我們就是在頂點著色器和片段著色器中做我們想要的處理,編寫了著色器程式碼之後,通過編譯連結成 OpenGL 程式。然後給 OpenGL 中設定的變數繫結對應的值,從頂點資料何處開始讀取值。到這裡,一切準備工作就做完了。
最後就在在渲染器 Renderer 中開始繪製了。
參考
1、《OpenGL ES 應用開發實踐指南》 2、《OpenGL 程式設計指南》(原書第八版) 3、https://github.com/glumes/AndroidOpenGLTutorial
問題
1、https://stackoverflow.com/questions/11286819/opengl-es-api-with-no-current-context
提示報錯:
call to OpenGL ES API with no current context (logged once per thread)
是因為,在 Render 的建構函式中初始化變數,並不是 OpenGL 的執行緒中。而是在主執行緒中。
最後,如果覺得文章不錯,歡迎關注微信公眾號:【紙上淺談】