OpenGL程式設計/科學OpenGL教程03

前兩個教程重點關注繪製曲線,但是如果您使用任何專業的繪圖工具,您會注意到圖形帶有標題、軸、刻度線、網格線、圖例以及(最重要的是,但經常被遺忘的)軸標籤。繪製圖形的大部分工作實際上是在繪製圖形周圍的所有內容。
您將遇到的最大問題是,一方面您擁有圖形座標,我們可以相對輕鬆地對其進行轉換以將圖形正確地放置在螢幕上,另一方面您擁有畫素座標,您希望將其用於圖形周圍的所有內容。在圖形的邊界附近,您希望繪製刻度線和座標的地方,這兩個座標空間將相遇。
在本教程中,我們將瞭解如何將繪圖繪製在一個比視窗稍小的矩形中,而不會使曲線洩漏出去。我們還將瞭解如何在圖形的左側和底部正確繪製刻度線,以便它們與曲線匹配。結果將開始類似於gnuplot的輸出。
這次我們將使用非常簡單但通用的著色器。頂點著色器將僅將變換矩陣應用於二維頂點
attribute vec2 coord2d;
uniform mat4 transform;
void main(void) {
gl_Position = transform * vec4(coord2d.xy, 0, 1);
}
我們將使用固定的純色,直接透過uniform傳遞到片段著色器
uniform vec4 color;
void main(void) {
gl_FragColor = color;
}
讓我們使用頂點緩衝物件儲存我們的資料,並使用我們可以使用鍵盤更改的變數offset_x和scale_x,繪製與第一個圖形教程中完全相同的圖形。為了使其看起來更專業,我們可以在白色背景上以紅色繪製它。
請記住,我們現在有一個頂點著色器,它希望一個變換矩陣而不是使用offset_x和scale_x本身。使用GLM,我們可以自己從這些變數建立矩陣,只需在單位矩陣上應用縮放和平移操作即可。然後我們可以將其傳送到頂點著色器
GLint uniform_transform = glGetUniformLocation(program, "transform");
glm::mat4 transform = glm::translate(glm::scale(glm::mat4(1.0f), glm::vec3(scale_x, 1, 1)), glm::vec3(offset_x, 0, 0));
glUniformMatrix4fv(uniform_transform, 1, GL_FALSE, glm::value_ptr(transform));
如果我們現在繪製圖形,它應該看起來與第一個教程相同(除了它是白色背景上的紅色)。圖形仍然覆蓋整個螢幕。相反,我們希望稍微縮小圖形,並使圖形周圍有一些空間用於刻度線和其他內容。讓我們為圖形的左側和底部保留一些刻度線空間,並在所有內容周圍留出邊距。我們希望空間獨立於視窗的大小,或者換句話說,它應該是固定數量的畫素。讓我們現在定義它
const int margin = 20;
const int ticksize = 10;
當然,現在我們有了變換矩陣,我們可以輕鬆地縮放和平移圖形。但是無論我們如何更改矩陣,這都不會阻止它繪製在整個螢幕上。我們可以手動確定哪些頂點位於我們繪圖指定區域內,並僅繪製這些頂點,但這將是一項非常繁重的工作。我們也可以先繪製繪圖,然後透過繪製填充的矩形來清除其周圍的區域。但是所有這些都很愚蠢,我們只想告訴GPU為我們裁剪繪圖。
我們可以使用多種OpenGL方法來裁剪繪圖。我們從glViewport()函式開始。這定義了視窗內要繪製的區域(以畫素為單位)。我們可以使用glutGet()呼叫來找出視窗的實際大小,並像這樣計算精確的區域
int window_width = glutGet(GLUT_WINDOW_WIDTH);
int window_height = glutGet(GLUT_WINDOW_HEIGHT);
glViewport(
margin + ticksize,
margin + ticksize,
window_width - margin * 2 - ticksize,
window_height - margin * 2 - ticksize
);
傳遞給glViewport()的前兩個引數是從視窗左下角開始的x和y偏移量(以畫素為單位)。後兩個引數是視口的寬度和高度(以畫素為單位)。嘗試在display()函式的頂部新增此內容。您應該會看到現在繪圖周圍確實有一個清晰的邊距。如果您增加邊距或刻度線大小常量,或者嘗試調整視窗大小,您會注意到視口不僅裁剪,而且還重新縮放繪圖,以便之前適合整個視窗的所有內容現在都將完全適合視口區域。對於我們的目的,這很好,因為我們不必想出自己的變換來補償繪圖周圍的邊距。
此時,您可能會認為glViewport()確實裁剪了指定區域之外的所有畫素。但是,這並不完全正確。發生的事情是幾何圖形被裁剪,以便所有將要繪製的頂點都位於視口區域內。不能保證片段會被裁剪,儘管某些顯示卡(例如nVidia)也可能自動執行此操作。您可以嘗試使用glLineWidth()函式使線條非常粗,具體取決於您的顯示卡,您可以看到線條的中心在視口區域的邊緣停止,但由於其厚度,畫素可能會洩漏到視口區域之外。(如果您繪製下一部分中提到的框,可能會幫助您瞭解視口在哪裡結束。)為了確保所有片段都被裁剪,您必須在設定視口後立即設定剪下區域並啟用剪下測試
glScissor(
margin + ticksize,
margin + ticksize,
window_width - margin * 2 - ticksize,
window_height - margin * 2 - ticksize
);
glEnable(GL_SCISSOR_TEST);
練習
- 除了前者執行的額外座標轉換之外,glViewport()與glScissor()之間是否有任何優勢?
- 嘗試將對glClear()的呼叫移動到glViewport()和glScissor()之間。它有區別嗎?如果在兩者之後立即呼叫它會發生什麼?
- 嘗試繪製一些具有非常大點大小的GL_POINTS,一些正好在視口區域內,一些正好在視口區域外。
- 嘗試再次繪製這些GL_POINTS,正好在視窗內和視窗外,而無需呼叫glViewport()。您能想到一種“修復”此行為的方法嗎?
下一步是在繪圖周圍繪製一個帶有刻度線的框。這次我們不希望發生任何裁剪,因此我們應該重置視口並停用剪下以再次覆蓋整個視窗
glViewport(0, 0, window_width, window_height);
glDisable(GL_SCISSOR_TEST);
現在的問題是我們失去了之前的自動座標轉換,因此我們不能再繪製角點為(-1, -1)和(1, 1)的框。不幸的是,沒有簡單的函式可以獲得與glViewport()應用相同的變換,因此我們將編寫我們自己的函式
glm::mat4 viewport_transform(float x, float y, float width, float height) {
// Calculate how to translate the x and y coordinates:
float offset_x = (2.0 * x + (width - window_width)) / window_width;
float offset_y = (2.0 * y + (height - window_height)) / window_height;
// Calculate how to rescale the x and y coordinates:
float scale_x = width / window_width;
float scale_y = height / window_height;
return glm::scale(glm::translate(glm::mat4(1), glm::vec3(offset_x, offset_y, 0)), glm::vec3(scale_x, scale_y, 1));
}
要理解此函式,只需想象您必須將視窗的中心移動到新視口的中心,並且您必須從視窗的寬度縮放到視口的寬度。我們現在可以使用與傳遞給glViewport()相同的引數呼叫此函式,並將結果傳遞給頂點著色器
transform = viewport_transform(
margin + ticksize,
margin + ticksize,
window_width - margin * 2 - ticksize,
window_height - margin * 2 - ticksize,
);
glUniformMatrix4fv(uniform_transform, 1, GL_FALSE, glm::value_ptr(transform));
然後我們用黑色繪製我們的框
GLuint box_vbo;
glGenBuffers(1, &box_vbo);
glBindBuffer(GL_ARRAY_BUFFER, box_vbo);
static const point box[4] = {{-1, -1}, {1, -1}, {1, 1}, {-1, 1}};
glBufferData(GL_ARRAY_BUFFER, sizeof box, box, GL_STATIC_DRAW);
GLfloat black[4] = {0, 0, 0, 1};
glUniform4fv(uniform_color, 1, black);
glVertexAttribPointer(attribute_coord2d, 2, GL_FLOAT, GL_FALSE, 0, 0);
glDrawArrays(GL_LINE_LOOP, 0, 4);
練習
- 我們可以在圖形之前繪製框嗎?或者可能在使用相同的glViewport()後立即繪製它?
現在我們已經繪製了曲線並在其周圍繪製了框,是時候繪製刻度線了。這些小線通常放置在整數值或非常圓的細分處,並且可以更輕鬆地估計函式在特定點的值。您還可以將其視為一把尺子,主刻度表示釐米,次刻度表示毫米。
我們將從框左側的刻度線開始,也稱為y軸。由於我們的繪圖具有-1..1的固定y範圍,因此計算正確的座標將非常容易。我們將嘗試繪製從-1到1的21個刻度線,間距為0.1。
此時,如果我們繼續使用繪製盒子時使用的相同變換矩陣,會更容易一些。這樣, 恰好對應盒子的左邊緣,而 和 分別對應盒子的底部和頂部。但我們如何從那裡開始繪製精確為刻度線大小 畫素 長度的線條呢?我們需要在圖形座標和畫素座標之間進行轉換。最重要的是,我們需要知道一個畫素在圖形單位中有多大。請記住,我們用來繪製盒子的座標範圍從 -1 到 1(因此寬度為 2 個單位),但我們將視口設定為window_width - border * 2 - ticksize寬。我們可以對高度使用相同的推理。因此,我們的畫素縮放因子將為
float pixel_x = 2.0 / (window_width - border * 2 - ticksize);
float pixel_y = 2.0 / (window_height - border * 2 - ticksize);
現在我們知道了這一點,我們可以計算出需要繪製 21 個刻度標記的 42 個頂點的座標,並將這些座標放入 VBO 中。
GLuint ticks_vbo;
glGenBuffers(1, &ticks_vbo);
glBindBuffer(GL_ARRAY_BUFFER, ticks_vbo);
point ticks[42];
for(int i = 0; i <= 20; i++) {
float y = -1 + i * 0.1;
ticks[i * 2].x = -1;
ticks[i * 2].y = y;
ticks[i * 2 + 1].x = -1 - ticksize * pixel_x;
ticks[i * 2 + 1].y = y;
}
glBufferData(GL_ARRAY_BUFFER, sizeof ticks, ticks, GL_STREAM_DRAW);
glVertexAttribPointer(attribute_coord2d, 2, GL_FLOAT, GL_FALSE, 0, 0);
glDrawArrays(GL_LINES, 0, 42);
請注意此處使用了 GL_STREAM_DRAW。儘管在本教程中 y 刻度始終相同,但在真實的繪圖程式中它們將是可變的。我們稍後也將重用此 VBO 用於 x 刻度。GL_STREAM_DRAW 指示我們只會使用這些頂點繪製一次。
我們可以透過將每個第二個 x 座標替換為以下內容,進一步區分主要刻度標記(位於單位值處)和次要刻度標記(每 0.1 個單位):
float tickscale = (i % 10) ? 0.5 : 1;
ticks[i * 2 + 1].x = -1 - ticksize * tickscale * pixel_x;
練習
- 嘗試將刻度標記放在另一個邊框上,或放在圖形內部而不是外部。
- 不要繪製刻度標記,而是在淺灰色中繪製水平網格線。你能想到一些使線條出現在曲線下方的幾種方法嗎?
- 主要刻度現在每 1 個單位繪製一次。將其更改為每 2.54 個單位繪製一次,次要刻度每 0.254 個單位繪製一次。
y 刻度標記很容易,因為我們從未在 y 軸上縮放或平移圖形。我們確切地知道從哪裡開始和結束。但是對於 x 軸,我們有兩個困難。首先,當我們平移圖形時,我們的刻度標記應該與圖形一起移動。但是當我們將圖形向左移動時,刻度標記應該在盒子的左邊緣消失,新的刻度標記應該出現在右邊緣。其次,如果我們更改圖形的比例,則刻度標記之間的間距應該相應調整。但是如果我們大幅縮小,則我們不希望在底部有數千個刻度標記。相反,我們希望每次超過 20 個刻度標記可見時,它們都會被精簡。類似地,如果我們大幅放大,則刻度標記的密度應每次少於 2 個刻度標記可見時增加 10 倍。
刻度標記所需的間距可以相對容易地找到。基本上,我們從scale_x變數知道圖形的比例。我們希望用它來縮放刻度標記之間的間距,但每次scale_x超過 10 的冪時,將其“重置”回 1。我們可以透過取scale_x的以 10 為底的對數,將其向下舍入到整數,然後將 10 乘以該整數的冪來獲得對數舍入的縮放因子(0.1、1、10、100 等)。在圖形單位中,次要刻度標記所需的間距為
float tickspacing = 0.1 * powf(10, -floor(log10(scale_x)));
為了找出應該在哪裡繪製最左邊和最右邊的刻度標記,我們將首先確定圖形可見部分的最左邊和最右邊的圖形座標是什麼。我們知道 x 座標在我們在其上繪製盒子的座標系中為 -1 和 1,因此我們必須應用我們用來繪製圖形的變換矩陣的逆。由於我們只對 x 座標感興趣,並且變換相當簡單,因此我們可以手動執行此操作,而不是使用 GLM。
float left = -1.0 / scale_x - offset_x;
float right = 1.0 / scale_x - offset_x;
但是,不能保證這些座標與刻度標記一致。我們確實知道原點至少有一個刻度標記,並且我們知道它們之間的間距。讓我們對刻度標記進行編號,從原點的 0 開始。然後我們可以確定兩個最接近但仍在左右邊緣之間的刻度標記的編號。
int left_i = ceil(left / tickspacing);
int right_i = floor(right / tickspacing);
然後我們知道最左邊刻度標記的座標(以圖形座標表示)僅僅是left_i * tickspacing。然後,左邊界與最左邊刻度標記之間的差異(以圖形單位表示)如下所示。
float rem = left_i * tickspacing - left;
現在我們可以計算我們將要繪製的座標系中最左邊刻度標記的座標了。
float firsttick = -1.0 + rem * scale_x;
我們還可以輕鬆計算繪圖座標中刻度標記之間的距離是多少,並且我們只需檢視left_i和right_i變數即可知道要繪製多少個刻度標記。如果我們做對了所有事情,那絕不應該超過 21 個刻度標記,但是始終最好嚴格施加限制,因為在對非常大或非常小的數字進行計算時(例如當您非常放大或縮小時)可能會發生奇怪的事情。由於我們已經對刻度標記進行了編號,因此我們還可以對 y 刻度標記使用相同的技巧來區分主要刻度標記和次要刻度標記。現在我們準備繪製 x 刻度標記了。
int nticks = right_i - left_i + 1;
if(nticks > 21)
nticks = 21;
for(int i = 0; i < nticks; i++) {
float x = firsttick + i * tickspacing * scale_x;
float tickscale = ((i + left_i) % 10) ? 0.5 : 1;
ticks[i * 2].x = x;
ticks[i * 2].y = -1;
ticks[i * 2 + 1].x = x;
ticks[i * 2 + 1].y = -1 - ticksize * tickscale * pixel_y;
}
glBufferData(GL_ARRAY_BUFFER, nticks * sizeof *ticks, ticks, GL_STREAM_DRAW);
glVertexAttribPointer(attribute_coord2d, 2, GL_FLOAT, GL_FALSE, 0, 0);
glDrawArrays(GL_LINES, 0, nticks * 2);
練習
- 使每四個刻度標記成為一個主要刻度標記。使刻度間距每次跨越 4 的冪而不是 10 的冪時重置。
- 使用left和right變數計算變換矩陣的逆。transform矩陣。