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(, , )); ;

总的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, );

性质

旋转、缩放操作都是关于原点(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()函数。

         /// <summary>
/// Build a look at view matrix.
/// transform object's coordinate from world's space to camera's space.
/// </summary>
/// <param name="eye">The eye.</param>
/// <param name="center">The center.</param>
/// <param name="up">Up.</param>
/// <returns></returns>
public static mat4 lookAt(vec3 eye, vec3 center, vec3 upVector)
{
// camera's back in world space coordinate system
vec3 back = (eye - center).normalize();
// camera's right in world space coordinate system
vec3 right = upVector.cross(back).normalize();
// camera's up in world space coordinate system
vec3 up = back.cross(right); mat4 viewMatrix = new mat4();
viewMatrix.col0.x = right.x;
viewMatrix.col1.x = right.y;
viewMatrix.col2.x = right.z;
viewMatrix.col0.y = up.x;
viewMatrix.col1.y = up.y;
viewMatrix.col2.y = up.z;
viewMatrix.col0.z = back.x;
viewMatrix.col1.z = back.y;
viewMatrix.col2.z = back.z; // Translation in world space coordinate system
viewMatrix.col3.x = -eye.dot(right);
viewMatrix.col3.y = -eye.dot(up);
viewMatrix.col3.z = -eye.dot(back); return viewMatrix;
}
 上述函数中的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()函数。

         /// <summary>
/// Creates a perspective transformation matrix.
/// </summary>
/// <param name="fovy">The field of view angle, in radians.</param>
/// <param name="aspect">The aspect ratio.</param>
/// <param name="zNear">The near depth clipping plane.</param>
/// <param name="zFar">The far depth clipping plane.</param>
/// <returns>A <see cref="mat4"/> that contains the projection matrix for the perspective transformation.</returns>
public static mat4 perspective(float fovy, float aspect, float zNear, float zFar)
{
float tangent = (float)Math.Tan(fovy / 2.0f);
float height = zNear * tangent;
float width = height * aspect; float left = -width, right = width, bottom = -height, top = height, near = zNear, far = zFar; mat4 result = frustum(left, right, bottom, top, near, far); return result;
}
/// <summary>
/// Creates a frustrum projection matrix.
/// </summary>
/// <param name="left">The left.</param>
/// <param name="right">The right.</param>
/// <param name="bottom">The bottom.</param>
/// <param name="top">The top.</param>
/// <param name="nearVal">The near val.</param>
/// <param name="farVal">The far val.</param>
/// <returns></returns>
public static mat4 frustum(float left, float right, float bottom, float top, float nearVal, float farVal)
{
var result = mat4.identity(); result[, ] = (2.0f * nearVal) / (right - left);
result[, ] = (2.0f * nearVal) / (top - bottom);
result[, ] = (right + left) / (right - left);
result[, ] = (top + bottom) / (top - bottom);
result[, ] = -(farVal + nearVal) / (farVal - nearVal);
result[, ] = -1.0f;
result[, ] = -(2.0f * farVal * nearVal) / (farVal - nearVal);
result[, ] = 0.0f; return result;
}

perspective

正交投影

正交投影就没有近大远小的效果:

正交矩阵的作用也是设置一个范围,将Camera Space里的顶点位置变换一下。

根据参数计算正交投影矩阵的函数就是著名的ortho()函数。

         /// <summary>
/// Creates a matrix for an orthographic parallel viewing volume.
/// </summary>
/// <param name="left">The left.</param>
/// <param name="right">The right.</param>
/// <param name="bottom">The bottom.</param>
/// <param name="top">The top.</param>
/// <param name="zNear">The z near.</param>
/// <param name="zFar">The z far.</param>
/// <returns></returns>
public static mat4 ortho(float left, float right, float bottom, float top, float zNear, float zFar)
{
var result = mat4.identity();
result[, ] = (2f) / (right - left);
result[, ] = (2f) / (top - bottom);
result[, ] = -(2f) / (zFar - zNear);
result[, ] = -(right + left) / (right - left);
result[, ] = -(top + bottom) / (top - bottom);
result[, ] = -(zFar + zNear) / (zFar - zNear);
return result;
} /// <summary>
/// Creates a matrix for projecting two-dimensional coordinates onto the screen.
/// <para>this equals ortho(left, right, bottom, top, -1, 1)</para>
/// </summary>
/// <param name="left">The left.</param>
/// <param name="right">The right.</param>
/// <param name="bottom">The bottom.</param>
/// <param name="top">The top.</param>
/// <returns></returns>
public static mat4 ortho(float left, float right, float bottom, float top)
{
var result = mat4.identity();
result[, ] = (2f) / (right - left);
result[, ] = (2f) / (top - bottom);
result[, ] = -(1f);
result[, ] = -(right + left) / (right - left);
result[, ] = -(top + bottom) / (top - bottom);
return result;
}

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如下。

 #version  core

 uniform mat4 mvp;
uniform float factor = 100.0f; in vec3 position; void main(void)
{
vec4 pos = mvp * vec4(position, 1.0f);
gl_PointSize = (1.0 - pos.z / pos.w) * factor;
gl_Position = pos;
}

试验1:把裁切掉的点放到中心位置

首先,我们把那些会被裁切掉的点(x或y或 z的绝对值大于等于|w|)放到中心位置试试。

 #version  core

 uniform mat4 mvp;
uniform float factor = 100.0f; in vec3 position; void main(void)
{
vec4 pos = mvp * vec4(position, 1.0f);
gl_PointSize = (1.0 - pos.z / pos.w) * factor;
if (abs(pos.x) >= abs(pos.w)
|| abs(pos.y) >= abs(pos.w)
|| abs(pos.z) >= abs(pos.w))
{
gl_Position = vec4(, , , );
}
else
{
gl_Position = pos;
}
}

结果如下:当没有电被裁切掉时,一切正常;

当有点被裁切掉时,就会在中心位置重叠很多点。

试验2:把没有裁切掉的点放到中心位置

这个试验和试验1相反,其vertex shader也是如此。

 #version  core

 uniform mat4 mvp;
uniform float factor = 100.0f; in vec3 position; void main(void)
{
vec4 pos = mvp * vec4(position, 1.0f);
gl_PointSize = (1.0 - pos.z / pos.w) * factor;
if (abs(pos.x) >= abs(pos.w)
|| abs(pos.y) >= abs(pos.w)
|| abs(pos.z) >= abs(pos.w))
{
gl_Position = pos;
}
else
{
gl_Position = vec4(, , , );
}
}

结果如下:当没有点被裁切时,只在中心位置有点存在,

当有点被裁切掉时,窗口周围就出现了很多点。

这也是我用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(, , Width, Height); 来告诉OpenGL:我们想把结果渲染到整个控件上。当然,如果控件大小发生改变,就需要再次调用 glViewport(, , Width, Height) ;。

还有一个不常见的 glDepthRange(float near, float far); 用于指定在Screen Space上的Z轴坐标范围(默认范围是 glDepthRange(, ) )。没错,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系统中,控件本身的 (, ) 位置是控件的左上角。即在mouse_down/mouse_move/mouse_up等事件中的(e.X, e.Y)是以左上角为原点,向右为X轴正方向,向下为Y轴正方向的。所以根据WinForm里的 (e.X, e.Y) 计算Screen Space里的坐标时要记得用 (e.X, Height - - e.Y)转换一下。

如果你用QQ截图或者其他任何方式截图,得到的窗口图片的Width、Height很可能是不等于用glViewport()得到的Width、Height的。截图得到的图片宽高受显示器分辨率的影响,不同的显示器得到的结果不尽相同。而用glViewport()得到的宽高无论在哪个显示器上都是一致的。Screen Space里用的Width、Height就是glViewport()版本的。这里也是个小坑。

+BIT祝威+悄悄在此留下版了个权的信息说:

Model Space<-->Screen Space

project

坐标变换过程很长很复杂?其实就那么回事。下面的函数就实现了从Model Space里的模型坐标到Window Space里的窗口坐标的变换过程。

         /// <summary>
/// Map the specified object coordinates (obj.x, obj.y, obj.z) into window coordinates.
/// </summary>
/// <param name="modelPosition">The object’s vertex position</param>
/// <param name="view">The view matrix</param>
/// <param name="proj">The projection matrix.</param>
/// <param name="viewport">The viewport.</param>
/// <returns></returns>
public static vec3 project(vec3 modelPosition, mat4 view, mat4 proj, vec4 viewport)
{
vec4 tmp = new vec4(modelPosition, (1f));
tmp = view * tmp;
tmp = proj * tmp;// this is gl_Position tmp /= tmp.w;// after this, tmp is normalized device coordinate. tmp = tmp * 0.5f + new vec4(0.5f, 0.5f, 0.5f, 0.5f);
tmp[] = tmp[] * viewport[] + viewport[];
tmp[] = tmp[] * viewport[] + viewport[];// after this, tmp is window coordinate. return new vec3(tmp.x, tmp.y, tmp.z);
}

就这么点事。当然这个函数忽略了model matrix和 glDepthRange() 的作用。不过model matrix可以和view matrix合二为一, glDepthRange() 基本上不需要调用。所以无伤大雅。

unProject

当然也有一个从Screen Space到Model Space的函数。完全是上面的project()的逆过程。

         /// <summary>
/// Map the specified window coordinates (win.x, win.y, win.z) into object coordinates.
/// </summary>
/// <param name="windowPos">The win.</param>
/// <param name="view">The view.</param>
/// <param name="proj">The proj.</param>
/// <param name="viewport">The viewport.</param>
/// <returns></returns>
public static vec3 unProject(vec3 windowPos, mat4 view, mat4 proj, vec4 viewport)
{
mat4 Inverse = glm.inverse(proj * view); vec4 tmp = new vec4(windowPos, (1f));
tmp.x = (tmp.x - (viewport[])) / (viewport[]);
tmp.y = (tmp.y - (viewport[])) / (viewport[]);
tmp = tmp * (2f) - new vec4(, , , );// after this, tmp is normalized device coordinate. vec4 obj = Inverse * tmp;
obj /= obj.w;// after this, tmp is model coordinate. return new vec3(obj);
}

好好体会这2个互逆的过程,就能看透OpenGL坐标变换的全过程。

CSharpGL(27)讲讲清楚OpenGL坐标变换的更多相关文章

  1. CSharpGL(49)试水OpenGL软实现

    CSharpGL(49)试水OpenGL软实现 CSharpGL迎来了第49篇.本篇内容是用C#编写一个OpenGL的软实现.暂且将其命名为SoftGL. 目前已经实现了由Vertex Shader和 ...

  2. OpenGL坐标变换专题

    OpenGL坐标变换专题(转)   OpenGL通过相机模拟.可以实现计算机图形学中最基本的三维变换,即几何变换.投影变换.裁剪变换.视口变换等,同时,OpenGL还实现了矩阵堆栈等.理解掌握了有关坐 ...

  3. OpenGL坐标变换及其数学原理,两种摄像机交互模型(附源程序)

    实验平台:win7,VS2010 先上结果截图(文章最后下载程序,解压后直接运行BIN文件夹下的EXE程序): a.鼠标拖拽旋转物体,类似于OGRE中的“OgreBites::CameraStyle: ...

  4. CSharpGL(39)GLSL光照示例:鼠标拖动太阳(光源)观察平行光的漫反射和镜面反射效果

    CSharpGL(39)GLSL光照示例:鼠标拖动太阳(光源)观察平行光的漫反射和镜面反射效果 开始 一图抵千言.首先来看鼠标拖动太阳(光源)的情形. 然后是鼠标拖拽旋转模型的情形. 然后我们移动摄像 ...

  5. BIT祝威博客汇总(Blog Index)

    +BIT祝威+悄悄在此留下版了个权的信息说: 关于硬件(Hardware) <穿越计算机的迷雾>笔记 继电器是如何成为CPU的(1) 继电器是如何成为CPU的(2) 关于操作系统(Oper ...

  6. CSharpGL(0)一个易学易用的C#版OpenGL

    +BIT祝威+悄悄在此留下版了个权的信说: CSharpGL(0)一个易学易用的C#版OpenGL CSharpGL是我受到SharpGL的启发,在整理了SharpGL,GLM,SharpFont等开 ...

  7. OpenGL 之 坐标变换

    http://www.cnblogs.com/irvinow/archive/2009/11/20/1606496.html 创建OpenGL模型过程: OPENGL坐标变换很有特点,为了简单描述先定 ...

  8. CSharpGL(1)从最简单的例子开始使用CSharpGL

    CSharpGL(1)从最简单的例子开始使用CSharpGL 2016-08-13 由于CSharpGL一直在更新,现在这个教程已经不适用最新的代码了.CSharpGL源码中包含10多个独立的Demo ...

  9. CSharpGL(24)用ComputeShader实现一个简单的图像边缘检测功能

    CSharpGL(24)用ComputeShader实现一个简单的图像边缘检测功能 效果图 这是红宝书里的例子,在这个例子中,下述功能全部登场,因此这个例子可作为使用Compute Shader的典型 ...

随机推荐

  1. Mysql事务探索及其在Django中的实践(一)

    前言 很早就有想开始写博客的想法,一方面是对自己近期所学知识的一些总结.沉淀,方便以后对过去的知识进行梳理.追溯,一方面也希望能通过博客来认识更多相同技术圈的朋友.所幸近期通过了博客园的申请,那么今天 ...

  2. LeetCode[3] Longest Substring Without Repeating Characters

    题目描述 Given a string, find the length of the longest substring without repeating characters. For exam ...

  3. Entity Framework 手动使用migration里面的up 和down方法。

    add-migration -IgnoreChanges 201606100717405_201606100645298_InitialCreate 执行这一句后 ,清空使用map生成的代码,个人不太 ...

  4. 利用apply()或者rest参数来实现用数组传递函数参数

    关于call()和apply()的用法,MDN文档里写的非常清晰明白,在这里就不多做记录了. https://developer.mozilla.org/zh-CN/docs/Web/JavaScri ...

  5. [干货来袭]C#6.0新特性

    微软昨天发布了新的VS 2015 ..随之而来的还有很多很多东西... .NET新版本 ASP.NET新版本...等等..太多..实在没消化.. 分享一下也是昨天发布的新的C#6.0的部分新特性吧.. ...

  6. 设置WindowServer2012 时间同步NTP

    在powershell中以管理员身份运行以下命令即可 w32tm /config /manualpeerlist:pool.ntp.org /syncfromflags:MANUAL Stop-Ser ...

  7. ReSharper详解Index0

    JetBrains ReSharper可以帮助Visual Studio用户编写出更好的代码.支持对C#,VB.NET,XAML,JavaScript,TypeScript,JSON,XML,HTML ...

  8. OpenWrt中开启usb存储和samba服务

    在从官网安装的WNDR3800 15.05.1版本OpenWrt中, 不带usb存储支持以及samba, 需要另外安装 1. 启用usb支持 USB Basic Support https://wik ...

  9. logstash服务启动脚本

    logstash服务启动脚本 最近在弄ELK,发现logstash没有sysv类型的服务启动脚本,于是按照网上一个老外提供的模板自己进行修改 #添加用户 useradd logstash -M -s ...

  10. Vue.js——基于$.ajax实现数据的跨域增删查改

    概述 之前我们学习了Vue.js的一些基础知识,以及如何开发一个组件,然而那些示例的数据都是local的.在实际的应用中,几乎90%的数据是来源于服务端的,前端和服务端之间的数据交互一般是通过ajax ...