文章出處

CSharpGL(27)講講清楚OpenGL坐標變換 

在理解OpenGL的坐標變換問題的路上,有好幾個難點和易錯點。且OpenGL秉持著程序難以調試、難點互相糾纏的特色,更讓人迷惑。本文依序整理出關于OpenGL坐標變換的各個知識點、隱藏規則、訣竅和注意事項。

+BIT祝威+悄悄在此留下版了個權的信息說:

Matrix

OpenGL用4x4矩陣進行坐標變換。

OpenGL的4x4矩陣是按排列的。

忘記glRotatef(),glScalef(),glTranslatef()什么的吧,那都屬于legacy opengl,不久會被徹底淘汰。在modern opengl中有其他方式代替他們。

+BIT祝威+悄悄在此留下版了個權的信息說:

Model Space

為了描述3D世界,首先要設計一些三維模型出來。

設計三維模型的時候用的坐標系就是Model Coordinate System。

只有1個模型

此時你所見的這個空間就是Model Space。Model Space里只負責描述一個模型。

有人可能會說,此圖只設計了一個茶壺,如果我設計的是一套茶具(茶壺+幾個茶杯),那不就是多個模型了嗎?答:還真不是,此時應該把這套茶具視作一個整體,視為一個模型。回憶一下中學學的"確定研究對象"、"將XXX視作一個整體",就是這個意思。

圍繞原點

在Model Space設計模型的時候,要注意使模型的包圍盒的中心位于原點(0, 0, 0)。    

包圍盒就是能夠把模型包圍的最小的長方體。

為什么要圍繞原點?因為這樣才能在下文所述的World Space里"正常地"旋轉、縮放和平移模型。

+BIT祝威+悄悄在此留下版了個權的信息說:

World Space

為何圍繞原點

繼續解釋上面的問題。假設我們設計了一個立方體模型,它是關于原點(0, 0, 0)對稱的。我們就這樣讓它降生到世界上。為了敘述方便,我們稱其為Center。如下圖所示。

(再換個角度看)

現在,我們再設計一個小一點的立方體模型,但這個立方體模型的中心不在原點(0, 0, 0)。為了敘述方便,我們稱其為Corner。我們把這個Corner也放進來。

(再換個角度看)

現在,我們分別把CenterCorner縮小為原來的一半。我們希望的情形是這樣的:

(再換個角度看)

為了看得清楚,我們把Center再擴大到原來的大小:

(再換個角度看)

可以看到Corner原來的位置上縮小了一半。這符合我們的預期。

但是,殘酷的現實并非如此,當你把CenterCorner同時縮小一半時,你看到的情形會是這樣:

(再換個角度看)

也就是說,一個縮放操作不僅改變了Corner的大小,還改變了它的位置。如果你在縮放之前把Camera對準了Corner,那么縮放之后Corner的位置發生了巨變,Camera很可能就看不到Corner了。

總結提升

如果一個模型的包圍盒A在Model Space的中心不是(0, 0, 0),那么你可以想象有一個虛擬的包圍盒B,B的中心是(0, 0, 0),且恰好能包圍住A。然后,同時縮放A和B。由于B的中心是(0, 0, 0),縮放前后不會改變;而A的中心實際上是B內部一側的一點,它是必然移動了的,即縮放操作改變了A的位置。

上述例子描述的是縮放操作,對于旋轉操作,道理相同。

這就是保持模型的包圍盒中心在原點(0, 0, 0)的好處。你可以隨意旋轉(rotate)、縮放(scale)模型,之后再移動(translate)到任意位置(此位置即模型在World Space里的位置)。無論你如何旋轉、縮放此模型,它在移動(translate)之后的位置都是一樣的。

如上圖所示,一個立方體向右移動4個單位,并進行了旋轉和縮放操作。無論旋轉角度、縮放比例是多少,其移動距離始終是4個單位。

Model Matrix

Model Matrix負責將模型從Model Space變換到World Space。

變換操作有三種:旋轉(rotation)、縮放(scale)和平移(translate)。可以按字母表的順序來記(Rotation, Scale, Translate)。

變換的順序應當是:1旋轉,2縮放,3平移。

設模型在Model Space里的任意一個頂點坐標為(x, y, z),我們想把模型放到World Space里的(tx, ty, tz)處,且繞y軸旋轉r°,縮放為原來的s倍。那么:

平移矩陣為 mat4 translate = glm.translate(mat4.identity(), new vec3(tx, ty, tz)); ;

縮放矩陣為 mat4 scale = glm.scale(mat4.identity(), new vec3(s, s, s)); ;

旋轉矩陣為 mat4 rotation = glm.rotate(mat4.identity(), (float)(r * Math.PI / 180.0), new vec3(0, 1, 0)); ;

總的Model Matrix為 mat4 modelMatrix = translate * scale * rotation; 。

為了獲取(x, y, z)變換到World Space上的位置,首先將其擴充為四元向量(x, y, z, 1)。(不用管為什么不是(x, y, z, 0)),然后可得:vec4 worldPos = modelMatrix * new vec4(x, y, z, 1); 

性質

旋轉、縮放操作都是關于原點(0, 0, 0)對稱的。把模型的包圍盒中心置于原點,會有難以言喻的好處。

 (worldPos.x, worldPos,y, worldPos.z) 就是 (x, y, z) 變換到World Space之后的位置。

+BIT祝威+悄悄在此留下版了個權的信息說:

 worldPos.w 必然是1。

對模型的操作順序應當為rotation -> scale -> translate。

View/Eye/Camera Space

這三個名稱是指同一個Space。

在World Space,各個模型都擺放好了位置和角度,之后就該從某個位置用Eye/Camera去看這個World。Camera有三個屬性:eye/Position描述其位置,center/Target是朝向,Up是頭頂。

Camera的Position是World Space里的一個點(Position.x, Position.y, Position.z),Target和Up是World Space里的2個向量。就是說,Camera.Position/Target/Up都是在World Space里定義的。

view matrix

Camera的參數(Position, Target, Up)決定了view matrix。模型在World Space里的位置,經過view matrix的變換,就變成了在View Space里的位置。

根據camera的Position, Target, Up求view matrix的過程就是著名的lookAt()函數。

 1         /// <summary>
 2         /// Build a look at view matrix.
 3         /// transform object's coordinate from world's space to camera's space.
 4         /// </summary>
 5         /// <param name="eye">The eye.</param>
 6         /// <param name="center">The center.</param>
 7         /// <param name="up">Up.</param>
 8         /// <returns></returns>
 9         public static mat4 lookAt(vec3 eye, vec3 center, vec3 upVector)
10         {
11             // camera's back in world space coordinate system
12             vec3 back = (eye - center).normalize();
13             // camera's right in world space coordinate system
14             vec3 right = upVector.cross(back).normalize();
15             // camera's up in world space coordinate system
16             vec3 up = back.cross(right);
17 
18             mat4 viewMatrix = new mat4(1);
19             viewMatrix.col0.x = right.x;
20             viewMatrix.col1.x = right.y;
21             viewMatrix.col2.x = right.z;
22             viewMatrix.col0.y = up.x;
23             viewMatrix.col1.y = up.y;
24             viewMatrix.col2.y = up.z;
25             viewMatrix.col0.z = back.x;
26             viewMatrix.col1.z = back.y;
27             viewMatrix.col2.z = back.z;
28 
29             // Translation in world space coordinate system
30             viewMatrix.col3.x = -eye.dot(right);
31             viewMatrix.col3.y = -eye.dot(up);
32             viewMatrix.col3.z = -eye.dot(back);
33 
34             return viewMatrix;
35         }
 上述函數中的right/up/back指的就是Camera的右側、上方、后面,如下圖所示。right/up/back是三個互相垂直的向量(構成一個右手系),且是在World Space中描述的。

上述函數得到的結果 viewMatrix 可以用下圖描述。[right/up/back]構成了旋轉和縮放的部分,-[right/up/back]*eye構成了平移的部分。right/up/back分別描述了Camera坐標系的X/Y/Z軸,且在 viewMatrix 里也依次位于第0/1/2行。

+BIT祝威+悄悄在此留下版了個權的信息說:

Clip Space

Camera擺好之后,要實現透視投影或正交投影。經過投影之后的坐標就是在Clip Space里的坐標。

透視投影

透視投影的效果就是近大遠小:

透視矩陣的作用就是設定下圖所示的一個棱臺范圍,將Camera Space里的頂點位置變換一下。變換效果就是遠處的點比變換之前更加靠近彼此,越遠就靠近的越多。想象一下把這個棱臺的Far面緩緩縮小到與Near面相同的大小,這一過程中,越遠的頂點,被擠壓的程度越大。

根據棱臺參數計算透視投影矩陣的函數就是著名的perspective()函數。

 1         /// <summary>
 2         /// Creates a perspective transformation matrix.
 3         /// </summary>
 4         /// <param name="fovy">The field of view angle, in radians.</param>
 5         /// <param name="aspect">The aspect ratio.</param>
 6         /// <param name="zNear">The near depth clipping plane.</param>
 7         /// <param name="zFar">The far depth clipping plane.</param>
 8         /// <returns>A <see cref="mat4"/> that contains the projection matrix for the perspective transformation.</returns>
 9         public static mat4 perspective(float fovy, float aspect, float zNear, float zFar)
10         {
11             float tangent = (float)Math.Tan(fovy / 2.0f);
12             float height = zNear * tangent;
13             float width = height * aspect;
14 
15             float left = -width, right = width, bottom = -height, top = height, near = zNear, far = zFar;
16 
17             mat4 result = frustum(left, right, bottom, top, near, far);
18 
19             return result;
20         }
21         /// <summary>
22         /// Creates a frustrum projection matrix.
23         /// </summary>
24         /// <param name="left">The left.</param>
25         /// <param name="right">The right.</param>
26         /// <param name="bottom">The bottom.</param>
27         /// <param name="top">The top.</param>
28         /// <param name="nearVal">The near val.</param>
29         /// <param name="farVal">The far val.</param>
30         /// <returns></returns>
31         public static mat4 frustum(float left, float right, float bottom, float top, float nearVal, float farVal)
32         {
33             var result = mat4.identity();
34 
35             result[0, 0] = (2.0f * nearVal) / (right - left);
36             result[1, 1] = (2.0f * nearVal) / (top - bottom);
37             result[2, 0] = (right + left) / (right - left);
38             result[2, 1] = (top + bottom) / (top - bottom);
39             result[2, 2] = -(farVal + nearVal) / (farVal - nearVal);
40             result[2, 3] = -1.0f;
41             result[3, 2] = -(2.0f * farVal * nearVal) / (farVal - nearVal);
42             result[3, 3] = 0.0f;
43 
44             return result;
45         }
perspective

正交投影

正交投影就沒有近大遠小的效果:

正交矩陣的作用也是設置一個范圍,將Camera Space里的頂點位置變換一下。

根據參數計算正交投影矩陣的函數就是著名的ortho()函數。

 1         /// <summary>
 2         /// Creates a matrix for an orthographic parallel viewing volume.
 3         /// </summary>
 4         /// <param name="left">The left.</param>
 5         /// <param name="right">The right.</param>
 6         /// <param name="bottom">The bottom.</param>
 7         /// <param name="top">The top.</param>
 8         /// <param name="zNear">The z near.</param>
 9         /// <param name="zFar">The z far.</param>
10         /// <returns></returns>
11         public static mat4 ortho(float left, float right, float bottom, float top, float zNear, float zFar)
12         {
13             var result = mat4.identity();
14             result[0, 0] = (2f) / (right - left);
15             result[1, 1] = (2f) / (top - bottom);
16             result[2, 2] = -(2f) / (zFar - zNear);
17             result[3, 0] = -(right + left) / (right - left);
18             result[3, 1] = -(top + bottom) / (top - bottom);
19             result[3, 2] = -(zFar + zNear) / (zFar - zNear);
20             return result;
21         }
22 
23         /// <summary>
24         /// Creates a matrix for projecting two-dimensional coordinates onto the screen.
25         /// <para>this equals ortho(left, right, bottom, top, -1, 1)</para>
26         /// </summary>
27         /// <param name="left">The left.</param>
28         /// <param name="right">The right.</param>
29         /// <param name="bottom">The bottom.</param>
30         /// <param name="top">The top.</param>
31         /// <returns></returns>
32         public static mat4 ortho(float left, float right, float bottom, float top)
33         {
34             var result = mat4.identity();
35             result[0, 0] = (2f) / (right - left);
36             result[1, 1] = (2f) / (top - bottom);
37             result[2, 2] = -(1f);
38             result[3, 0] = -(right + left) / (right - left);
39             result[3, 1] = -(top + bottom) / (top - bottom);
40             return result;
41         }
ortho

性質

無論是透視投影還是正交投影,都有以下性質:

在Clip Space里的頂點位置(x, y, z, w),“x, y, z的絕對值都小于等于|w|”等價于“此頂點在可見范圍之內”。

在Clip Space里的頂點位置(x, y, z, w),就是在vertex shader里賦值給 gl_Position 的值。

證明(20161119)

為了證明上面的性質,我們來做個試驗。

正常的Point Sprite

首先我們來渲染一個正常的點精靈。如圖所示,這些點環繞成一個球形,并且填充了立方體的下半部分。

 

 其vertex shader如下。

 1 #version 150 core
 2 
 3 uniform mat4 mvp;
 4 uniform float factor = 100.0f;
 5 
 6 in vec3 position;
 7 
 8 void main(void)
 9 {
10     vec4 pos = mvp * vec4(position, 1.0f);
11     gl_PointSize = (1.0 - pos.z / pos.w) * factor;
12     gl_Position = pos;
13 }

試驗1:把裁切掉的點放到中心位置

首先,我們把那些會被裁切掉的點(x或y或 z的絕對值大于等于|w|)放到中心位置試試。

 1 #version 150 core
 2 
 3 uniform mat4 mvp;
 4 uniform float factor = 100.0f;
 5 
 6 in vec3 position;
 7 
 8 void main(void)
 9 {
10     vec4 pos = mvp * vec4(position, 1.0f);
11     gl_PointSize = (1.0 - pos.z / pos.w) * factor;
12     if (abs(pos.x) >= abs(pos.w)
14 || abs(pos.y) >= abs(pos.w) 15 || abs(pos.z) >= abs(pos.w)) 16 { 17 gl_Position = vec4(0, 0, 0, 1); 18 } 19 else 20 { 21 gl_Position = pos; 22 } 23 }

結果如下:當沒有電被裁切掉時,一切正常;

 

當有點被裁切掉時,就會在中心位置重疊很多點。

試驗2:把沒有裁切掉的點放到中心位置

這個試驗和試驗1相反,其vertex shader也是如此。

 1 #version 150 core
 2 
 3 uniform mat4 mvp;
 4 uniform float factor = 100.0f;
 5 
 6 in vec3 position;
 7 
 8 void main(void)
 9 {
10     vec4 pos = mvp * vec4(position, 1.0f);
11     gl_PointSize = (1.0 - pos.z / pos.w) * factor;
12     if (abs(pos.x) >= abs(pos.w)
14 || abs(pos.y) >= abs(pos.w) 15 || abs(pos.z) >= abs(pos.w)) 16 { 17 gl_Position = pos; 18 } 19 else 20 { 21 gl_Position = vec4(0, 0, 0, 1); 22 } 23 }

結果如下:當沒有點被裁切時,只在中心位置有點存在,

當有點被裁切掉時,窗口周圍就出現了很多點。

這也是我用Point Sprite做證明的原因:即使被裁切掉,仍然會有一部分顯示出來,方便證明。

+BIT祝威+悄悄在此留下版了個權的信息說:

Normalized Device Space

從Clip Space到Normalized Device Space很簡單,只需將(x, y, z, w)全部除以w即可。這被稱為視角除法(perspective division)。

上一節說過,在可見范圍里的(x, y, z, w),x, y, z的絕對值都小于等于|w|。因此,經過視角除法后,所有可見的頂點位置都介于(-1, -1, -1)和(1, 1, 1)之間。

視角除法這一過程是由OpenGL渲染管線自動完成的,我們無需也不能參與。

+BIT祝威+悄悄在此留下版了個權的信息說:

Screen/Window Space

最后一步,就是把(-1, -1, -1)和(1, 1, 1)之間的頂點位置轉換到二維的屏幕窗口上。

假設用于OpenGL渲染的控件寬、高為Width、Height。

 glViewport(int x, int y, int width, int height); 用于指定將渲染結果鋪到控件的哪一塊上。一般我們用 glViewport(0, 0, Width, Height); 來告訴OpenGL:我們想把結果渲染到整個控件上。當然,如果控件大小發生改變,就需要再次調用 glViewport(0, 0, Width, Height) ;。

還有一個不常見的 glDepthRange(float near, float far); 用于指定在Screen Space上的Z軸坐標范圍(默認范圍是 glDepthRange(0, 1) )。沒錯,Screen Space也是有第三個坐標軸Z的,且其方向是從你的計算機窗口指向里面。

頂點在Screen Space里的位置是按下面的公式計算的,當然也是OpenGL自動完成的,我們無需也無法參與。

這個公式很簡單,通過NDC(Normalized Dived Coordinate)和Window Coordinate System的線性關系可知:

當我們用 glViewport(x, y, Width, Height); 的設定時,Screen Space的原點在 (x, y) ,X軸正方向向右,Y軸正方向向上,Z軸正方向向里。即這是一個左手系。(這個 (x, y) 是相對控件的左下角而言的,即Screen Space的X軸、Y軸是貼在WinForm控件上的)

注意事項

在WinForm系統中,控件本身的 (0, 0) 位置是控件的左上角。即在mouse_down/mouse_move/mouse_up等事件中的(e.X, e.Y)是以左上角為原點,向右為X軸正方向,向下為Y軸正方向的。所以根據WinForm里的 (e.X, e.Y) 計算Screen Space里的坐標時要記得用 (e.X, Height - 1 - e.Y)轉換一下。

如果你用QQ截圖或者其他任何方式截圖,得到的窗口圖片的Width、Height很可能是不等于用glViewport()得到的Width、Height的。截圖得到的圖片寬高受顯示器分辨率的影響,不同的顯示器得到的結果不盡相同。而用glViewport()得到的寬高無論在哪個顯示器上都是一致的。Screen Space里用的Width、Height就是glViewport()版本的。這里也是個小坑。

+BIT祝威+悄悄在此留下版了個權的信息說:

Model Space<-->Screen Space

project

坐標變換過程很長很復雜?其實就那么回事。下面的函數就實現了從Model Space里的模型坐標到Window Space里的窗口坐標的變換過程。

 1         /// <summary>
 2         /// Map the specified object coordinates (obj.x, obj.y, obj.z) into window coordinates.
 3         /// </summary>
 4         /// <param name="modelPosition">The object’s vertex position</param>
 5         /// <param name="view">The view matrix</param>
 6         /// <param name="proj">The projection matrix.</param>
 7         /// <param name="viewport">The viewport.</param>
 8         /// <returns></returns>
 9         public static vec3 project(vec3 modelPosition, mat4 view, mat4 proj, vec4 viewport)
10         {
11             vec4 tmp = new vec4(modelPosition, (1f));
12             tmp = view * tmp;
13             tmp = proj * tmp;// this is gl_Position
14 
15             tmp /= tmp.w;// after this, tmp is normalized device coordinate.
16 
17             tmp = tmp * 0.5f + new vec4(0.5f, 0.5f, 0.5f, 0.5f);
18             tmp[0] = tmp[0] * viewport[2] + viewport[0];
19             tmp[1] = tmp[1] * viewport[3] + viewport[1];// after this, tmp is window coordinate.
20 
21             return new vec3(tmp.x, tmp.y, tmp.z);
22         }

就這么點事。當然這個函數忽略了model matrix和 glDepthRange() 的作用。不過model matrix可以和view matrix合二為一, glDepthRange() 基本上不需要調用。所以無傷大雅。

unProject

當然也有一個從Screen Space到Model Space的函數。完全是上面的project()的逆過程。

 1         /// <summary>
 2         /// Map the specified window coordinates (win.x, win.y, win.z) into object coordinates.
 3         /// </summary>
 4         /// <param name="windowPos">The win.</param>
 5         /// <param name="view">The view.</param>
 6         /// <param name="proj">The proj.</param>
 7         /// <param name="viewport">The viewport.</param>
 8         /// <returns></returns>
 9         public static vec3 unProject(vec3 windowPos, mat4 view, mat4 proj, vec4 viewport)
10         {
11             mat4 Inverse = glm.inverse(proj * view);
12 
13             vec4 tmp = new vec4(windowPos, (1f));
14             tmp.x = (tmp.x - (viewport[0])) / (viewport[2]);
15             tmp.y = (tmp.y - (viewport[1])) / (viewport[3]);
16             tmp = tmp * (2f) - new vec4(1, 1, 1, 1);// after this, tmp is normalized device coordinate.
17 
18             vec4 obj = Inverse * tmp;
19             obj /= obj.w;// after this, tmp is model coordinate.
20 
21             return new vec3(obj);
22         }

 

好好體會這2個互逆的過程,就能看透OpenGL坐標變換的全過程。


文章列表


不含病毒。www.avast.com
arrow
arrow
    全站熱搜
    創作者介紹
    創作者 大師兄 的頭像
    大師兄

    IT工程師數位筆記本

    大師兄 發表在 痞客邦 留言(0) 人氣()