ETH官方钱包

前往
大廳
主題

【程式】OpenGL 3.3 架設基本繪圖管線

Shark | 2021-05-24 11:15:17 | 巴幣 1414 | 人氣 1535

由於目標是Direct3D和OpenGL的只看其中一篇就能看懂,有些內容和D3D篇重覆。

把「Direct3D與OpenGL的繪圖管線(上)」的圖拿出來看一下。

這次要做的是把左邊列的物件和設定值一樣一樣弄好,除了constant buffer、貼圖和sampler以外,本篇先不用到貼圖。

本篇重點是將頂點資料和shader傳給GPU。OpenGL 2.0的世代不一定要用到shader,而且有方法可以「上傳頂點資料→繪製」兩個工作同時做,但3.2以後的core profile就一定要寫vertex shader和fragment shader,且上傳頂點資料一定要照「建立buffer物件→上傳頂點資料→繪製」的步驟,所以初學階段就得學習怎麼做這兩件事。

本系列的OpenGL目標版本是3.3,一些早期的方法就不介紹了,也不使用4.0以後新增的功能。

因為本篇新介紹的東西很多,在此把shader和vertex array object的一些細節略過,先寫個能用的程式再說,之後教到貼圖和shader輸入輸出時再介紹。

之前的教學可以看這篇目錄,「Direct3D與OpenGL」的部分。
Shark流程式教學一覽




#define GL_GLEXT_PROTOTYPES
#define GLX_GLXEXT_PROTOTYPES
#include<GL/gl.h> //間接引用GL/glext.h
#include<GL/glx.h> //間接引用X11/Xlib.h

#include<stdio.h>
#include<time.h>
//使用clock_gettime()和nanosleep()

const int WINDOW_W=200,WINDOW_H=200;
//OpenGL global狀態
Display* dsp;
Window window;
GLXContext context;
//OpenGL物件
uint32_t programID;
uint32_t vertexData;
uint32_t vertexArrayObj;

//要傳給GPU的資料
const float VERTICES[]={
0,0.8, 0.8,0, -0.5,-0.5,
};

const char VERTEX_SHADER[]="#version 330\n \
layout(location=0) in vec2 pos; \
void main(){ \
  gl_Position=vec4(pos, 0, 1); \
}"
;

const char FRAGMENT_SHADER[]="#version 330\n \
void main(){ \
  gl_FragColor=vec4(1,1,1,1); \
}"
;

//這六個函式在下面說明
static int initGL(){
  ……
}
static int compileShader(uint32_t shaderID, const char* code, const char* name){
  ……
}
static int initSettings(){
  ……
}
static void nextFrame(){
  ……
}
static void deinitSettings(){
  ……
}
static void deinitGL(){
  ……
}

int main(){
  dsp = XOpenDisplay( NULL );
  window = XCreateSimpleWindow(dsp,
    DefaultRootWindow(dsp),
    0, 0,WINDOW_W, WINDOW_H, //xywh
    0, 0, 0);

  if(initGL()){
    printf("Can not initialize OpenGL\n");
    return 0;
  }
  if(initSettings()){
    return 0;
  }

  //設標題
  XStoreName(dsp, window, "simplepipeline");
  //設定事件mask
  Atom wmDelete = XInternAtom(dsp, "WM_DELETE_WINDOW", True);
  XSetWMProtocols(dsp, window, &wmDelete, 1);
  XMapWindow( dsp, window );

  //接收事件
  XEvent evt;
  int isEnd=0;
  struct timespec prevTime,nextTime;
  while(!isEnd){
    clock_gettime(CLOCK_MONOTONIC, &prevTime);
    while(XPending(dsp)){
      XNextEvent(dsp, &evt);
      switch(evt.type){
      case ClientMessage:
        if(evt.xclient.data.l[0]== wmDelete){ isEnd=1; }
        break;
      }
    }

    //正式寫遊戲時,遊戲邏輯放在此處

    nextFrame(); //更新畫面

    clock_gettime(CLOCK_MONOTONIC, &nextTime); //單位為奈秒(10^-9秒)
    int64_t elapsedTime = (nextTime.tv_sec-prevTime.tv_sec)*1000000000
      +(nextTime.tv_nsec-prevTime.tv_nsec); //求出經過的奈秒數
    struct timespec sleepTime={0, 16000000-elapsedTime};
    if(sleepTime.tv_nsec>0){ nanosleep(&sleepTime, NULL); }
  }

  XDestroyWindow( dsp, window );
  XFlush(dsp);
  deinitSettings();
  deinitGL();
  XCloseDisplay( dsp );
  return 0;
}
大部分和「OpenGL 3.3初始化(X Window)」相同,不同的地方是宣告了幾個uint32_t代表OpenGL的物件,以及計時器採用正式的做法了。
1. nanosleep()之後立刻呼叫clock_gettime()取得目前時間。
2. 做完處埋輸入、遊戲邏輯、繪圖後再取得一次目前時間,將兩者相減算出經過的時間。
3. 再算出應該暫停多久,如果經過時間<16ms才暫停。
Linux取得目前時間有多種方式,其中最符合本篇需求的是clock_gettime(),第一參數填CLOCK_MONOTONIC則取得的是電腦開機後到現在的時間,修改系統時間不影響這個計時器。
傳回值是這個struct:
struct timespec{
  time_t tv_sec;  //秒
  long   tv_nsec; //秒以下的部分,單位為奈秒,最大值為999,999,999
};
//64位元程式裡兩個欄位都是64 bit
由於傳回的單位是奈秒,暫停使用同樣是奈秒的nanosleep()。

其他可以計時到毫秒以下的函式,linux.die.net上面的說明:
clock_gettime(),第一參數填不同的值可取得不同的計時器。
clock(),這個process佔用的CPU時間,sleep經過的時間會忽略。
gettimeofday(),秒的部分傳回unix time stamp,會受修改系統時間影響。

還多了這部分。
//要傳給GPU的資料
const float VERTICES[]={
0,0.8, 0.8,0, -0.5,-0.5,
};

const char VERTEX_SHADER[]="#version 330\n \
layout(location=0) in vec2 pos; \
void main(){ \
  gl_Position=vec4(pos, 0, 1); \
}"
;

const char FRAGMENT_SHADER[]="#version 330\n \
void main(){ \
  gl_FragColor=vec4(1,1,1,1); \
}"
;
寫一組最簡單的shader,vertex shader把坐標原封不動傳給rasterizer,pixel shader把像素塗成白色,shader的輸出入、語法等等的之後另寫一篇介紹。
第一行「#version 330」指定shader版本號,這行一定要有,否則會被視為最舊的1.1版編譯,很多功能都不能用。
GLSL版本與OpenGL版本的對應看這篇:OpenGL wiki: Core Language (GLSL)#Compilation_settings
GLSL的程式進入點必須是main(),不像HLSL可以自己指定函式,把兩個shader寫在同一個字串就會出現兩個main(),所以要分成兩個字串。

三個頂點坐標是(0, 0.8)、(0.8, 0)、(-0.5, -0.5),以前有講過D3D和OpenGL的畫面坐標是-1 ~ 1,可以預想畫出的三角形是這樣。


static int initGL(){
  const int fbConfigAttr[]={
    GLX_X_RENDERABLE,True,
    GLX_DOUBLEBUFFER,True,
    GLX_X_VISUAL_TYPE, GLX_TRUE_COLOR,
    GLX_RED_SIZE, 8, GLX_GREEN_SIZE, 8, GLX_BLUE_SIZE, 8, GLX_ALPHA_SIZE,8,
    None
  };
  int fbCount;
  GLXFBConfig* fbc = glXChooseFBConfig(dsp, DefaultScreen(dsp),fbConfigAttr,&fbCount);
  if(fbc==NULL){ return 1; }

  //產生OpenGL 3.3 context
  const int contextAttribs[] = {
    GLX_CONTEXT_MAJOR_VERSION_ARB,3,
    GLX_CONTEXT_MINOR_VERSION_ARB,3,
    GLX_CONTEXT_PROFILE_MASK_ARB, GLX_CONTEXT_CORE_PROFILE_BIT_ARB,
    None,
  };
  context = glXCreateContextAttribsARB(dsp, fbc[0], 0,True, contextAttribs);
  XFree(fbc);
  if(context==0){ return 1; }
  glXMakeCurrent(dsp, window, context);

  //設定Vsync
  PFNGLXSWAPINTERVALMESAPROC glXSwapIntervalMESA=
    (PFNGLXSWAPINTERVALMESAPROC)glXGetProcAddress("glXSwapIntervalMESA");
  if(glXSwapIntervalMESA){
    glXSwapIntervalMESA(0);
  }
  PFNGLXSWAPINTERVALSGIPROC glXSwapIntervalSGI=
    (PFNGLXSWAPINTERVALSGIPROC)glXGetProcAddress("glXSwapIntervalSGI");
  if(glXSwapIntervalSGI){
    glXSwapIntervalSGI(0);
  }
  return 0;
}
initGL()和之前大同小異。



之後的initSettings()是重點,不過看程式碼之前先了解一下OpenGL的「物件」是什麼。OpenGL不是用C/C++的struct或物件,而是用無號32位元整數代表它內部的物件,程式裡可以宣告成OpenGL自訂的GLuint,或C語言標準的unsigned int、uint32_t。
OpenGL官方文件把這些整數稱為object或name,我是習慣稱為ID(識別碼),以免跟C/C++的物件或字串混淆。
至於整數怎麼對應到驅動程式或顯示記憶體內的資料,OpenGL規格有要求晶片廠商自己想辦法,外面傳整數進來驅動程式要找得到實際的資料。

一般使用物件的方法以vertex array object為例:
  1. 用glGenVertexArrays()產生可用的識別碼。
    它的原形宣告是「void glGenVertexArrays(GLsizei n, GLuint* arrays);」,可以一次產生多個,第一參數給數量,第二參數給可儲存足夠int的空間。
    產生的值隨晶片和平臺而異,有的平臺會傳回流水號但有的平臺不是,不能預先假設它是什麼,但可以確定的是不會產生0。
  2. 用glBindVertexArray()套用物件,如「Direct3D與OpenGL的繪圖管線(下)」所說,可以想成是叫顯卡少女把物件放在工作臺上,之後draw call就會使用它。
    有些物件例外,bind只是把ID指定給內部一個全域變數,要不要用在draw call是用其他函式設定。
    填0有什麼作用依物件種類而定,有的代表不套用任何物件,有的是套用一個系統內建的物件,如0號framebuffer代表畫面。
  3. 在物件裡填入資料,以及指定buffer和texture的大小是bind之後再用其他函式操作。
  4. 用完後呼叫glDeleteVertexArrays()刪除物件。原形宣告是「void glDeleteVertexArrays(GLsizei n, const GLuint* arrays);」,跟glGenVertexArrays()一樣可以一次刪除多個。
本篇還有buffer object,以後還會出現texture、sampler、framebuffer等等的都是類似的函式名稱。
shader和program的函式沒有照這個規則所以官方文件稱它們為unconventional object,但用法差不多。

-Vertex and fragment shader-

從準備shader開始,如果shader有錯就直接結束程式。D3D稱為pixel shader的東西在OpenGL稱為fragment shader。
shader是給GPU執行的程式,跟C/C++一樣要先把程式碼編譯成binary才能給GPU使用,OpenGL的shader程式語言稱為GLSL。
OpenGL 3.3必須把shader原始碼附在程式裡,程式執行時送給驅動程式編譯,不像D3D有事先編譯成中間碼的方法,直到4.6版才新增自己的中間碼格式SPIR-V。
//傳回0代表成功、非0代表失敗
//name是用在顯示錯誤訊息,OpenGL不會用到

static int compileShader(uint32_t shaderID, const char* code, const char* name){
  glShaderSource(shaderID, 1, &code, NULL);
  glCompileShader(shaderID);
  //檢查shader錯誤
  int ret;
  glGetShaderiv(shaderID, GL_COMPILE_STATUS, &ret); //ret=0代表有錯誤
  if(!ret){
    int infoLen;
    glGetShaderiv(shaderID, GL_INFO_LOG_LENGTH, &infoLen); //取得訊息長度
    char info[infoLen]; //配置空間
    glGetShaderInfoLog(shaderID, infoLen, NULL, info); //取得訊息
    printf("%s:\n%s",name, info);
  }
  return !ret;
}

static int initSettings(){
  //compile vertex shader
  uint32_t vsID=glCreateShader(GL_VERTEX_SHADER);
  int vsStatus=compileShader(vsID, VERTEX_SHADER, "vertex shader");
  //compile fragment shader
  uint32_t fsID=glCreateShader(GL_FRAGMENT_SHADER);
  int fsStatus=compileShader(fsID, FRAGMENT_SHADER, "fragment shader");
  if(vsStatus || fsStatus){
    glDeleteShader(vsID);
    glDeleteShader(fsID);
    return 1;
  }
先寫一個輔助函式compileShader(),然後才開始initSettings()。
● 用glCreateShader()產生一個shader識別碼,
● 用glShaderSource()載入shader程式碼,第二、第三參數是陣列長度和陣列,可以一次給它多個字串,它會把字串連接起來視為一段長的code,第四參數是各個字串的長度,填NULL會把字串視為null terminate string,偵測null字元求出長度。
OpenGL wiki的glShaderSource()說明
● 用glCompileShader()編譯,檢查shader有沒有寫錯的方法請參照上面程式碼。
fragment shader也按照這個方法準備。

  //create program object
  programID=glCreateProgram();
  glAttachShader(programID, vsID);
  glAttachShader(programID, fsID);
  glLinkProgram(programID);
  glDetachShader(programID, vsID);
  glDetachShader(programID, fsID);
  glDeleteShader(vsID); //之後不會再用到shader物件,可刪除
  glDeleteShader(fsID);
  //check program error
  int ret;
  glGetProgramiv(programID,GL_LINK_STATUS, &ret);
  if(!ret){
    int infoLen;
    glGetProgramiv(programID, GL_INFO_LOG_LENGTH, &infoLen);
    char info[infoLen];
    glGetProgramInfoLog(programID, infoLen, NULL, info);
    printf("program linking:\n%s", info);
    glDeleteProgram(programID);
    return 1;
  }
  glUseProgram(programID);
光有shader物件還不能用,還要準備program物件,它是個容器把vertex、fragment、geometry各階段的shader包起來,以後套用program物件就是套用這一組shader。
● 用glCreateProgram()產生program識別碼。
● 用glAttachShader()將shader載入program物件。
● 用glLinkProgram()連結,會檢查各shader宣告的uniform變數有沒有一致、vertex shader的輸出和fragment shader的輸入有沒有配合等等,本篇的shader比較簡單,還比較看不出效果。
● link之後shader物件就不需要了,將它detach之後可以用glShaderSource()重新載入程式碼,用來產生另一個program,如果不會再用到就可刪除。
檢查link有沒有錯誤的方法請參照上面程式碼。
● 最後還要用glUseProgram()套用物件,之後呼叫draw call就會套用這一組shader。

一旦link之後就不可變更其中的shader,如果你有3個vertex shader和3個fragment shader,9種組合都會用到,那只好建立9個program物件了。
4.1版新增了separate program的功能,可以自由切換其中一個階段的shader,要呼叫完全不同的函式來使用。
OpenGL wiki的Separate programs說明

-Vertex buffer-

  //vertex buffer
  glGenBuffers(1, &vertexData);
  glBindBuffer(GL_ARRAY_BUFFER, vertexData);
  glBufferData(GL_ARRAY_BUFFER, sizeof(VERTICES),VERTICES, GL_STATIC_DRAW);
再來建立buffer物件並把頂點坐標從主記憶體傳給顯卡少女。先Gen、Bind,到glBufferData()這行才設定buffer大小並傳送資料。
Buffer Object的說明
glBufferData()說明
「glBindBuffer(GL_ARRAY_BUFFER, vertexData)」可以想成是把vertexData存在內部一個叫GL_ARRAY_BUFFER的全域變數,「glBufferData(GL_ARRAY_BUFFER,……)」讀取這個全域變數得知要操作哪個buffer。
GL_ARRAY_BUFFER是例外的物件之一,bind只是設一個全域變數而不是把它用在draw call,頂點資料來源是在下面vertex array object設定。

-Input assembler-

  //input assembler
  glGenVertexArrays(1, &vertexArrayObj);
  glBindVertexArray(vertexArrayObj);
  glEnableVertexAttribArray(0);
  glVertexAttribPointer(0,2,GL_FLOAT,0,0,0);
Direct3D與OpenGL的繪圖管線(上)」講過,把頂點資料傳給顯卡少女時,她看到的只是一堆byte,還要告訴她資料是什麼格式。本篇使用OpenGL 3.0新增的方法:vertex array object (VAO)。
照標準物件的用法,Gen產生識別碼、Bind套用。
之後用glEnableVertexAttribArray()告訴顯卡少女啟用index=0的資料,用glVertexAttribPointer()設定格式,參數如下:
1:index,跟glEnableVertexAttribArray()的參數配合。
2:頂點資料都是視為向量,這個參數設定有幾個分量,本篇的坐標有X、Y兩個分量。
3:分量的資料型態。
4、5、6:現在先略過,等之後教到貼圖再介紹用法。
雖然參數沒有明確寫出來,它還會讀取GL_ARRAY_BUFFER得知0號資料來自哪個buffer。
glVertexAttribPointer()說明
OpenGL wiki關於vertex array object的說明

頂點坐標可以有不止一項資料,有幾頂資料就要呼叫幾次glEnableVertexAttribArray()和glVertexAttribPointer(),本篇只有位置一項資料,index和第5,6參數的用途在資料有兩項以上時才看得出來。
呼叫glVertexAttribPointer()之後,「頂點資料來自vertexData」和「格式為float[2]」就儲存在VAO裡了,以後bind這個VAO就會套用這些資訊。
幾何形狀和頂點數量不是在這裡告訴顯卡少女,而是之後draw call時才設定。

-Rasterizer, depth, stencil, and blend-

  //rasterizer
  glDisable(GL_CULL_FACE); //default
  glDisable(GL_MULTISAMPLE);
  glViewport(0, 0, WINDOW_W, WINDOW_H);

  //depth and stencil
  glDisable(GL_DEPTH_TEST); //default
  glDisable(GL_STENCIL_TEST); //default

  //blend

  glEnable(GL_BLEND);
  glBlendEquation(GL_FUNC_ADD); //default
  glBlendFunc(GL_ONE, GL_ZERO); //default

  //設定清除畫面的顏色

  const float color[]={0,0,0,0};
  glClearColor(color[0],color[1],color[2],color[3]);
}
//結束initSettings()
接下來只是設定值,不用建立物件就比較簡單。OpenGL調整設定值基本上是一個函式修改一個值,跟D3D11必須建立物件不一樣。
先看glEnable()這個函式。
glEnable()、glDisable()說明
不論pipeline的哪個步驟,只要是on, off的設定值都用這個函式設定,且OpenGL出新版本時會追加功能,因此能填旳flag很多。
想用某個功能通常要先用glEnable()啟用,然後用一些函式設定詳細功能,例如想啟用culling要先呼叫glEnable(GL_CULL_FACE),然後用glCullFace()和glFrontFace()調culling的設定。

rasterizer通常要調的設定是culling和multisample,本篇不需要這兩個功能。
還要設viewport,x,y,width,height的單位是像素而不是-1~1,且要注意OpenGL是以視窗左下角為原點,跟一般繪圖軟體不一樣。
glViewport()說明
忘記culling和viewport是什麼的話可以看這篇複習:Direct3D與OpenGL的繪圖管線(上)

本篇不做depth test和stencil test,也根本沒配置depth buffer,用glDisable()關閉功能。

Blend是指pixel的輸出和現在畫面上的像素要用什麼算式計算,本篇用pixel shader的輸出直接取代掉舊的值。

最後用glClearColor()把顏色指定給一個全域變數,之後用glClear()清除畫面會套用這個顏色。

有幾行後面有寫//default,OpenGL context建立完成時就會填好一些設定值,這幾行剛好跟預設的值相同,把這幾行拿掉執行結果不會變,可以讓程式短一些,但如果你無法記住全部預設值,怕弄錯那寫上去也無妨。

本篇省略錯誤檢查的code,但順便介紹一下OpenGL檢查錯誤的方法。
查函式宣告可以看到大部分函式傳回值都是void,OpenGL並不是用函式傳回值傳回錯誤碼,發生error時它會把錯誤碼記在內部一個變數,呼叫glGetError()可取得錯誤碼。
glGetError()說明
發生error之後錯誤碼會一直保留,直到下一次發生error才會修改,而glGetError()會在傳回錯誤碼同時把錯誤碼設為0,所以要用如下方式:
//呼叫一次將錯誤碼設為0
glGetError();
//vertex buffer
glGenBuffers(1, &vertexData);
glBindBuffer(GL_ARRAY_BUFFER, vertexData);
glBufferData(GL_ARRAY_BUFFER, sizeof(VERTICES),VERTICES, GL_STATIC_DRAW);

//input assembler
glGenVertexArrays(1, &vertexArrayObj);
glBindVertexArray(vertexArrayObj);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0,0,GL_FLOAT,0,0,0); //第二參數故意填錯的值

//再呼叫一次glGetError()
printf("error: %x\n", glGetError());
如果第二次glGetError()傳回非0,表示兩次glGetError()之間發生錯誤,可以修改glGetError()的位置縮小範圍。
這個方法只能檢查填錯參數之類的,不能檢查邏輯錯誤,如設定頂點格式時byte數填錯,這時會看到畫面上顯示的不是我們想要的,但glGetError()傳回0,有時候用一些debug工具才比較方便。



static void nextFrame(){
  glClear(GL_COLOR_BUFFER_BIT);
  glDrawArrays(GL_TRIANGLES, 0, 3);
  glXSwapBuffers(dsp, window);
}
實際畫出畫面,用glClear()清除畫面,然後用glDrawArrays()畫出三角形,第一參數是幾何形狀,第三參數是頂點數量。
本篇只畫一個物體,所以各種設定值和VAO、program物件只在初始化時套用一次,之後就不更換,如果要畫多個物體,每次繪製之前要切換物件和設定值。
另外「Direct3D與OpenGL的繪圖管線(下)」有講到,glDrawArrays()時才真正使用這些設定值,因此initSettings()裡調設定的順序可以互換,不用照繪圖管線的步驟。

static void deinitSettings(){
  glDeleteProgram(programID);
  glDeleteBuffers(1, &vertexData);
  glDeleteVertexArrays(1, &vertexArrayObj);
}
刪除物件的方法如上面介紹的glDelete*函式,只有program物件比較不一樣。

static void deinitGL(){
  glXDestroyContext(dsp, context);
}
deinitGL和之前一樣。

假設檔名是simplepipeline.c,用這個指令build。
gcc simplepipeline.c -o simplepipeline -s -Os -lX11 -lGL

在Linux執行的樣子。

之前說過在Linux用printf()顯示訊息,要從命令列執行才看得到,如果點滑鼠執行遇到視窗沒出現等問題,可以從命令列執行看訊息。



之前說過以後OpenGL的範例程式只會寫Linux版,如果要在Windows用OpenGL,請參照「OpenGL 3.3初始化 (Windows)」這篇初始化,然後以下函式要照前兩行的方法用wglGetProcAddress()取得函式指標才能使用。
PFNGLCREATESHADERPROC glCreateShader=
  (PFNGLCREATESHADERPROC)wglGetProcAddress("glCreateShader");
glShaderSource
glCompileShader
glGetShaderiv
glGetShaderInfoLog

glCreateProgram
glAttachShader
glLinkProgram
glDetachShader
glDeleteShader
glGetProgramiv
glGetProgramInfoLog
glUseProgram

glGenBuffers
glBindBuffer
glBufferData

glGenVertexArrays
glBindVertexArray
glEnableVertexAttribArray
glVertexAttribPointer

glDeleteProgram
glDeleteBuffers
glDeleteVertexArrays
覺得取得函式指標的工作很煩人的話,有個函式庫GLEW能幫忙做這件事,可以用用看。
http://glew.sourceforge.net/



本篇的color theme是Breeze Dark,是KDE Plasma桌面內附的,也有移植到其他軟體上。本系列的OpenGL部分都會用這個配色。

VS code版下載頁面 https://marketplace.visualstudio.com/items?itemName=kde.breeze

一個KDE的軟體:KWrite的畫面

創作回應

??求出處學術用??
感謝教學
2021-07-26 20:06:12

更多創作