GLKit实战 第03话 变换

@Daniate  January 30, 2020

疑惑

本文首发于Daniate的个人网站,文章链接:https://daniate.com/2020/01/30/183.html

在第02话中,已经绘制出来了一个三角形,那么可能就会有以下的疑问:

  1. 为什么三角形中相互垂直的两条边,长度不一致?
  2. 如何才能实现三角形中相互垂直的两条边长度一致?

关于这些疑问,均受到顶点位置、模型视图矩阵、投影矩阵、视口的影响,因此,在解决问题之前,会对相关的理论知识进行说明。

注:已将清除色设置为白色,glClearColor(1.0f, 1.0f, 1.0f, 1.0f);

先上一张插图,展示的是第02话中绘制的三角形:

在这张插图中,已标注了x、y轴,两条垂直的边a和b,以及三角形顶点位置的坐标。

坐标系统

我们使用的坐标系统是默认的笛卡尔坐标系统,它有如下特点:

  1. 包含x、y、z三个坐标轴,且两两之间相互垂直。
  2. x为横坐标轴,其正方向为从左到右。
  3. y为纵坐标轴,其正方向为从下到上。
  4. z为垂直于屏幕的坐标轴,其正方向为从屏幕下方到屏幕上方,指向用户。

视景体

视景体是一个六面体,用于框定最终可以渲染到屏幕上的物体。在后面的投影变换中,会更具体地进行说明。

视口

视口是屏幕窗口中的某个区域,用于绘制视景体中的物体,也就是说,视景体最终会被映射到屏幕窗口中的某个区域,而这个区域,就是视口。

在视口中,坐标系统还有另外两个特点:

  1. 在x、y、z三个方向上,范围均为[-1, 1](注意,是闭区间,没有具体的单位,因此可以将其想象为毫米、分米、千米等)。
  2. 原点,也即坐标为(0, 0, 0)的位置,位于视口的中心。

结合上面的插图,可能会问,原点不是位于屏幕的中心吗?下面就回答这个问题:

通常情况下,视口大小等同于窗口大小,而默认情况下,窗口大小又等同于屏幕大小(以像素为单位)。

上面的插图,就是视口大小等同于窗口大小,因此难免会有疑惑。

视口的位置及大小,可通过glViewport函数进行修改。

变换

在物体呈现到屏幕的过程中,需要经历视图变换、模型变换、投影变换、视口变换。

视图变换相当于相机对准某个场景的过程,调整的是相机的位置及其对准的方向。

模型变换相当于对场景中的物体进行摆放的过程,调整的是场景中物体的位置、旋转角度等。

投影变换相当于调整相机镜头焦距并由胶卷将场景记录下来的过程,调整的是视角的大小、景深的大小。

视口变换相当于确定相片的最终大小的过程,最终打印出来的相片,可能是1吋的,也可能是8吋的。

视图变换、模型变换、投影变换,可以通过GLKBaseEffecttransform属性进行控制,更具体些,transform中的modelviewMatrix控制的是视图变换和模型变换,projectionMatrix控制的是投影变换。

GLKBaseEffect_transform.png
Transform_modelview_projection_matrix.png

模型视图变换

用于将位置坐标从世界空间转换到视觉空间。

GLKBaseEffecttransformmodelviewMatrix,默认是一个单位矩阵,也就意味着,观察点位于世界空间坐标原点,指向z轴的负方向,以y轴的正方向为朝上的方向。

默认情况下的视觉空间:

通过GLKMatrix4MakeLookAt函数,可以直观地设置用于进行模型视图变换的矩阵。它用于设置观察点的位置、指定朝向哪个位置进行观看,以及朝上的方向。

函数原型如下:

GLKMatrix4 GLKMatrix4MakeLookAt(
    float eyeX, float eyeY, float eyeZ, 
    float centerX, float centerY, float centerZ, 
    float upX, float upY, float upZ
);

eyeXeyeYeyeZ,用于指定观察点的位置。

centerXcenterYcenterZ,用于指定对准哪个位置进行观察,由于有了观察点的位置,因此也就指明了观察的朝向方向,也即沿着由(eyeX, eyeY, eyeZ)(centerX、centerY、centerZ)所形成的直线进行观察,方向为从(eyeX, eyeY, eyeZ)(centerX、centerY、centerZ)的方向。

upXupYupZ,用于指定观察点朝上的方向。

注意,所指定的观察点的位置,是模型视图变换进行之前的位置,当变换结束后,观察点依旧位于原点(变换前后,所使用的空间,是不一样的)。

可以回顾一下中学物理知识,运动是相对的,当你朝着某个物体前进时,也就意味着物体朝向你前进。

GLKMatrix4MakeLookAt生成的矩阵,最终是作用于物体的,并不是作用于观察点的。比如,GLKMatrix4MakeLookAt(1, 0, 2, 1, 0, 0, 0, 1, 0),指定观察点位于(1, 0, 2),被观看的位置是(1, 0, 0),也就意味着,依旧是朝向z轴负方向进行观看,它所生成的矩阵如下:

 1  0   0  0
 0  1   0  0 
 0  0   1  0 
-1  0  -2  1

应该已经注意到,第1列最后一行是-1,第3列最后一行是-2,它们分别表示,物体需要朝着x轴负方向平移1个单位,物体需要朝着z轴负方向平移2个单位。

如下图所示:

很明显,那个立方体,朝着z轴负方向平移了2个单位,朝着x轴负方向平移了1个单位(此时的视觉空间,其坐标轴为图中的红色坐标轴)

投影变换

用于将位置坐标从视觉空间转换到投影空间。

通过投影矩阵,可以定义一个由6个平面包围着的空间区域,这就是视景体,位于视景体之外的部分,最终将会被忽略掉,不会呈现到最终的画面中。

投影可分为两种:

  1. 正投影
  2. 透视投影

正投影

在正投影下,视景体是一个长方体,相交的平面是相互垂直的,相互平行的两个面是大小一致的,也就是说,视景体是正交平行的。当同一个物体完全处于视景体内,无论它位于哪个位置,最终看到的效果,物体大小都会完全一致。

正投影视景体:

通过GLKMatrix4MakeOrtho函数,可以直观地设置用于正投影的矩阵。

函数原型:

GLKMatrix4 GLKMatrix4MakeOrtho(
    float left, float right, 
    float bottom, float top, 
    float nearZ, float farZ
);

这些参数,针对的是视觉空间。

leftright指定视景体的左右范围,也就是左右两侧的平面在x轴上的位置。

topbottom指定视景体的上下范围,也就是上下两侧的平面在y轴上的位置。

nearZfarZ指定视景体的前后范围,也就是近平面、远平面在z轴上的位置。

GLKBaseEffecttransformprojectionMatrix,默认是一个单位矩阵,因此也就会形成正投影,其左右上下前后范围分别是-1、1、1、-1、1、-1。

透视投影

类似于视觉感官,对于同一个物体,会产生近大远小的效果。当某个物体距离近平面较近、较远时,最终呈现的大小是不一样的,较近的会大一些,而较远的会小一些。

透视投影视景体(近平面与远平面是平行的,近平面的长宽小于远平面的长宽):

前面已经提到,GLKBaseEffecttransformprojectionMatrix,默认是一个单位矩阵,会形成正投影,如果想要产生透视投影,就需要改变projectionMatrix

通过GLKMatrix4MakeFrustumGLKMatrix4MakePerspective,可以直观地设置透视投影矩阵。

函数原型如下:

GLKMatrix4 GLKMatrix4MakeFrustum(
    float left, float right, 
    float bottom, float top, 
    float nearZ, float farZ
);
GLKMatrix4 GLKMatrix4MakePerspective(
    float fovyRadians, float aspect, 
    float nearZ, float farZ
);

这些参数,针对的也是视觉空间。

GLKMatrix4MakeFrustum:

leftright指定视景体近平面的左右范围,也就是近平面左右两侧在x轴上的位置。

topbottom指定视景体近平面的上下范围,也就是近平面上下两侧在y轴上的位置。

nearZ指定视景体近平面与观察点之间的距离。

farZ指定视景体远平面与观察点之间的距离。

其中,nearZfarZ必须为正值,且farZ必须大于nearZ(实际上,可以小于nearZ,只要不相等就行。属于GLKit代码实现中的小瑕疵)。

虽然参数很好理解,但比起GLKMatrix4MakePerspective,在使用时,GLKMatrix4MakeFrustum并没有很直观,因此,在大多数情况下,使用的都是GLKMatrix4MakePerspective

GLKMatrix4MakePerspective:

fovyRadians指定在yz平面(由y轴和z轴所形成的平面)上的视野角度,单位为弧度。

aspect指定视景体近平面的宽高比。

nearZfarZ,与GLKMatrix4MakeFrustum中的一样。

它所产生的视景体,总是上下对称、左右对称的(对称面分别为xz平面、yz平面)。因此,当需要产生上下不对称或左右不对称的视景体时,使用GLKMatrix4MakeFrustum会更便利。

视口变换

经过投影变换后,为了让物体展示到窗口之中,还需要进行视口变换。

之前讲视口时,已经提到过,其坐标系统的范围,在三个方向上均为[-1, 1]。

视口变换就是把视景体内的物体,映射到视口的过程。无论近平面有多大,经过映射后,其上的位置最终都会被限制在[-1, 1]之中。

将视景体映射到方形的视口中(插图中左侧的,是透过视景体近平面所看到的效果):

将视景体映射到扁平的视口中,很明显,最终的效果发生了形变(插图中左侧的,是透过视景体近平面所看到的效果):

处理疑惑

结合第02话中的代码,以及上面的理论知识,就很容易理解,为什么三角形中相互垂直的两条边,长度是不一致的。

由于默认情况下,GLKBaseEffecttransformprojectionMatrix,是一个单位矩阵,会形成正投影,视景体的范围,在[-1, 1]之间。

第02话中的代码没有设置projectionMatrix,也就是使用的是默认值。

默认情况下,视口的大小,就是窗口的大小。在第02话中的代码中,没有通过调用glViewport来调整视口,因此视口也就是全屏的,其宽度与高度是不一致的。

因此形成了下面的映射:

为了让最终的两条边的长度一致,可以采取以下处理方式(可任选其一):

  1. 直接调整顶点的位置。
  2. 调整模型视图矩阵,可以间接调整顶点的位置。
  3. 调整投影矩阵,让视景体近平面的宽高比,与视口的宽高比一致。
  4. 调整视口,使其与视景体近平面的宽高比一致。

因为有4种选择,因此,先定义几个枚举值,以及一个属性,用于标记所采用的是哪种选择。

typedef NS_ENUM(NSUInteger, TransformFlag) {
    TransformFlagDefault,
    TransformFlagVertices,
    TransformFlagModelviewMatrix,
    TransformFlagProjectionMatrix,
    TransformFlagViewport,

    TransformFlagNum,
};

@property (nonatomic, assign) TransformFlag transformFlag;

直接调整顶点位置

通过调整顶点位置,让经过投影变换(默认情况下是正投影)后的三角形:

  • 在竖直方向上扁一些(适用于视口宽度不大于高度的情况)
  • 在水平方向上瘦一些(适用于视口宽度不小于高度的情况)
- (GLKVector3)_adjustPosition:(GLKVector3)position size:(CGSize)size {
    if (MIN(size.width, size.height) <= DBL_EPSILON) {
        return position;
    }
    float aspect = size.width / size.height;
    if (aspect < 1) {
        return GLKVector3Make(position.x, position.y * aspect, position.z);
    }
    return GLKVector3Make(position.x / aspect, position.y, position.z);
}

插图中,红色虚线圈定的区域,就是修改了顶点位置后,所产生的三角形。

调整模型视图矩阵

直接调整顶点位置,需要对每个顶点进行手动调整,十分笨拙,如果顶点有很多,就更麻烦了。为了方便,可以通过调整modelviewMatrix达到间接调整顶点位置的目的:

  • 在竖直方向上缩小一些(适用于视口宽度不大于高度的情况)
  • 在水平方向上缩小一些(适用于视口宽度不小于高度的情况)
- (void)_adjustModelviewMatrixIfNeeded:(CGSize)size {
    if (MIN(size.width, size.height) <= DBL_EPSILON) {
        return;
    }
    if (TransformFlagModelviewMatrix == self.transformFlag) {
        float aspect = size.width / size.height;
        if (aspect < 1) {
            self.effect.transform.modelviewMatrix = GLKMatrix4Scale(GLKMatrix4Identity, 1, aspect, 1);
        } else {
            self.effect.transform.modelviewMatrix = GLKMatrix4Scale(GLKMatrix4Identity, 1 / aspect, 1, 1);
        }
    } else {
        // reset to default value
        self.effect.transform.modelviewMatrix = GLKMatrix4Identity;
    }
}

调整投影矩阵

调整投影矩阵,让视景体近平面的宽高比,与视口的宽高比一致。

如果要使用正投影,那么需要:

  • 调整视景体的上下范围(适用于视口宽度不大于高度的情况)
  • 调整视景体的左右范围(适用于视口宽度不小于高度的情况)

如果要使用透视投影,就需要:

当使用GLKMatrix4MakeFrustum时,需要调整的东西和正投影一样,也是上下左右的范围。

当使用GLKMatrix4MakePerspective时,只需要调整宽高比。

当然,在调整视景体时,注意一定要确定物体处于视景体中,否则最终的画面可能会空空如也,因此,也可能需要调整modelviewMatrix

为了简单起见,在代码中依旧使用正投影:

if (TransformFlagProjectionMatrix == self.transformFlag) {
    float aspect = size.width / size.height;
    if (aspect < 1) {
        float bottom = -1 / aspect;
        float top = 1 / aspect;
        self.effect.transform.projectionMatrix = GLKMatrix4MakeOrtho(-1, 1, bottom, top, 1, -1);
    } else {
        float left = -1 * aspect;
        float right = 1 * aspect;
        self.effect.transform.projectionMatrix = GLKMatrix4MakeOrtho(left, right, -1, 1, 1, -1);
    }
} else {
    // reset to default value
    self.effect.transform.projectionMatrix = GLKMatrix4Identity;
}

插图中,红色虚线圈定的区域,就是新设定的视景体近平面,它的宽高比,与视口的宽高比相同。

调整视口

调整视口,使其与视景体近平面的宽高比一致。

默认情况下,用的投影矩阵是单位矩阵,因此视景体近平面的宽高比为1(正投影视景体,范围为[-1, 1],这已经在之前提到过)。

因此,只需将视口的宽高调整为大小一致即可:

UIScreen *screen = [UIScreen mainScreen];
CGFloat scale = [screen respondsToSelector:@selector(nativeScale)] ? screen.nativeScale : screen.scale;
CGFloat widthInPixel = size.width * scale;
CGFloat heightInPixel = size.height * scale;

if (TransformFlagViewport == self.transformFlag) {
    CGFloat wh = MIN(widthInPixel, heightInPixel);
    CGFloat x = (widthInPixel - wh) / 2;
    CGFloat y = (heightInPixel - wh) / 2;
    glViewport(x, y, wh , wh);
} else {
    // reset to default value
    glViewport(0, 0, widthInPixel , heightInPixel);
}

插图中,红色虚线圈定的区域,就是新设定的视口,它处于屏幕的中间区域,宽高相等。

最终效果

竖屏.gif
横屏.gif

对比4种处理方式

直接调整顶点位置,前面已经说过,会显得十分笨拙,几乎不会选择这种处理方式。

考虑到要充分利用窗口空间,将视口调整为窗口上的一小部分,并不是最佳选择,因为这样的话,窗口的其他区域就不会展示内容了,因此,保持视口占用整个窗口即可。

修改模型视图矩阵、投影矩阵,才是最佳的处理方式。

知识共享许可协议
本作品由Daniate采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。


添加新评论