绘制圆柱体

获取示例代码,本文代码在分支chapter15中。


本文将要介绍如何使用代码绘制一个圆柱体,通过绘制圆柱体可以更好的掌握法线,UV,TriangleFan,TriangleStrip等相关知识。在绘制之前,先进行一些准备工作。

GLGeometry

为了更方便的进行顶点数据的管理,我创建了一个GLGeometry类。

typedef enum : NSUInteger {
    GLGeometryTypeTriangles,
    GLGeometryTypeTriangleStrip,
    GLGeometryTypeTriangleFan,
} GLGeometryType;

typedef struct {
    GLfloat x;
    GLfloat y;
    GLfloat z;
    GLfloat normalX;
    GLfloat normalY;
    GLfloat normalZ;
    GLfloat u;
    GLfloat v;
} GLVertex;

@interface GLGeometry () {
    GLuint vbo;
    BOOL vboValid;
}
@property (strong, nonatomic) NSMutableData *vertexData;
@end

@implementation GLGeometry

- (instancetype)initWithGeometryType:(GLGeometryType)geometryType
{
    self = [super init];
    if (self) {
        self.geometryType = geometryType;
        vboValid = NO;
        self.vertexData = [NSMutableData data];
    }
    return self;
}

- (void)dealloc {
    if (vboValid) {
        glDeleteBuffers(1, &vbo);
    }
}

- (void)appendVertex:(GLVertex)vertex {
    void * pVertex = (void *)(&vertex);
    NSUInteger size = sizeof(GLVertex);
    [self.vertexData appendBytes:pVertex length:size];
}

- (GLuint)getVBO {
    if (vboValid == NO) {
        glGenBuffers(1, &vbo);
        vboValid = YES;
        glBindBuffer(GL_ARRAY_BUFFER, vbo);
        glBufferData(GL_ARRAY_BUFFER, [self.vertexData length], self.vertexData.bytes, GL_STATIC_DRAW);
    }
    return vbo;
}

- (int)vertexCount {
    return [self.vertexData length] / sizeof(GLVertex);
}

这个类里我定义了描述顶点数据的结构体GLVertex,描述顶点绘制方式的枚举GLGeometryType,追加顶点数据的方法- (void)appendVertex:(GLVertex)vertex,生成VBO的方法- (GLuint)getVBO,获取顶点个数的方法- (int)vertexCount。有了这些我们就可以很方便的构建3D几何体了。

分解圆柱体

如果我们有一个纸质的圆柱体模型,我们可以把它剪开成两个圆形和一个矩形。

所以我们可以将圆柱体看做三个几何体来绘制,绘制两个圆形和一个卷成桶状的矩形。我们将圆形半径定义为radius,矩形高为height,宽既是圆形的周长。

下面绘制的代码在Cylinder类中,Cylinder继承自GLObject

绘制圆形

可以采取多边形逼近的方式绘制圆形,比如我们可以构建一个正36边形来表示一个圆。本文的代码就是利用这个原理来绘制圆的。定义构成圆形的边数为sideCount

- (GLGeometry *)topCircle {
    if (_topCircle == nil) {
        _topCircle = [[GLGeometry alloc] initWithGeometryType:GLGeometryTypeTriangleFan];
    
        float y = self.height / 2.0;
        // 中心点
        GLVertex centerVertex = GLVertexMake(0, y, 0, 0, 1, 0, 0.5, 0.5);
        [_topCircle appendVertex:centerVertex];
        for (int i = self.sideCount; i >= 0; --i) {
            GLfloat angle = i / (float)self.sideCount * M_PI * 2;
            GLVertex vertex = GLVertexMake(cos(angle) * self.radius, y, sin(angle) * self.radius, 0, 1, 0, (cos(angle) + 1 ) / 2.0, (sin(angle) + 1 ) / 2.0);
            [_topCircle appendVertex:vertex];
        }
    }
    return _topCircle;
}

上面是Cylinder.m中的代码,用来构建圆柱体上方的圆形。构建圆形是我使用的是TriangleFan,可以大大减少绘制需要的顶点数。首先添加圆心的顶点,然后围绕中心顶点,依次加入边上的顶点。上面的法线都是朝上的,既(0, 1, 0)。UV和顶点的取值如下图所示。

图示为5条边的情况演示,第n条边的Angle等于2 * Pi * n / sideCount,因为sin函数的范围是-1到1,所以使用(sin(angle) + 1 ) / 2.0就可以得到0~1的uv范围。

下方的圆形和上方主要的区别就是y轴的位置和法线,它位于-height/2处,法线向下。上方的圆形处于height/2处,法线向上。

- (GLGeometry *)bottomCircle {
    if (_bottomCircle == nil) {
        _bottomCircle = [[GLGeometry alloc] initWithGeometryType:GLGeometryTypeTriangleFan];
        
        float y = -self.height / 2.0;
        // 中心点
        GLVertex centerVertex = GLVertexMake(0, y, 0, 0, -1, 0, 0.5, 0.5);
        [_bottomCircle appendVertex:centerVertex];
        for (int i = 0; i <= self.sideCount; ++i) {
            GLfloat angle = i / (float)self.sideCount * M_PI * 2;
            GLVertex vertex = GLVertexMake(cos(angle) * self.radius, y, sin(angle) * self.radius, 0, -1, 0, (cos(angle) + 1 ) / 2.0, (sin(angle) + 1 ) / 2.0);
            [_bottomCircle appendVertex:vertex];
        }
    }
    return _bottomCircle;
}

细心的读者可能还会发现,循环的顺序也不一样,上面是 for (int i = self.sideCount; i >= 0; --i),下面是for (int i = 0; i <= self.sideCount; ++i)。为什么要这样呢?因为我开启了剔除表面,glEnable(GL_CULL_FACE);,并且剔除的是背面glCullFace(GL_BACK);。剔除背面就意味着背面将不会被渲染,只有正面面向摄像机的时候我们才能看到它被渲染。那么OpenGL如何判断正面还是背面呢?

- (void)draw:(GLContext *)glContext {
    glEnable(GL_CULL_FACE);
    glCullFace(GL_BACK);
    ...
}

Cull Face

默认情况下,投影到屏幕后顶点顺序为逆时针的面为正面。

图中右边的是逆时针,所以如果使用了Cull Face,我们只能看见右边的面。当然你也可以使用void glFrontFace(GLenum mode);将顺时针改为正面。

因为我需要顶部圆形的上面一侧显示,所以必须保证从上往下看时,组成三角形的顶点顺序是逆时针的。底部的圆形则相反,从下往上看时,需要保证组成三角形的顶点顺序是逆时针的。

绘制中间的矩形

中间的矩形可以使用三角带来绘制。

- (GLGeometry *)middleCylinder {
    if (_middleCylinder == nil) {
        _middleCylinder = [[GLGeometry alloc] initWithGeometryType:GLGeometryTypeTriangleStrip];
        
        float yUP = self.height / 2.0;
        float yDOWN = -self.height / 2.0;
        for (int i = 0; i <= self.sideCount; ++i) {
            GLfloat angle = i / (float)self.sideCount * M_PI * 2;
            GLKVector3 vertexNormal = GLKVector3Normalize(GLKVector3Make(cos(angle) * self.radius, 0, sin(angle) * self.radius));
            GLVertex vertexUp = GLVertexMake(cos(angle) * self.radius, yUP, sin(angle) * self.radius, vertexNormal.x, vertexNormal.y, vertexNormal.z, i / (float)self.sideCount, 0);
            GLVertex vertexDown = GLVertexMake(cos(angle) * self.radius, yDOWN, sin(angle) * self.radius, vertexNormal.x, vertexNormal.y, vertexNormal.z, i / (float)self.sideCount, 1);
            [_middleCylinder appendVertex:vertexDown];
            [_middleCylinder appendVertex:vertexUp];
        }
    }
    return _middleCylinder;
}

可以把它看做self.sideCount个矩形组成的几何体。只需要按照下图方向依次追加顶点即可。

注意添加顶点时候我使用的是从0到2Pi的方向,正如上图所示,这样才能保证顶点顺序是逆时针的。UV直接使用顶点在宽高上的比例即可。

绘制圆柱体

有了这三个几何体,就可以组合成一个圆柱体了。下面是绘制代码。

- (void)draw:(GLContext *)glContext {
    glEnable(GL_CULL_FACE);
    glCullFace(GL_BACK);
    [glContext setUniformMatrix4fv:@"modelMatrix" value:self.modelMatrix];
    bool canInvert;
    GLKMatrix4 normalMatrix = GLKMatrix4InvertAndTranspose(self.modelMatrix, &canInvert);
    [glContext setUniformMatrix4fv:@"normalMatrix" value:canInvert ? normalMatrix : GLKMatrix4Identity];
    [glContext bindTexture:self.diffuseTexture to:GL_TEXTURE0 uniformName:@"diffuseMap"];
    [glContext drawGeometry:self.topCircle];
    [glContext drawGeometry:self.bottomCircle];
    [glContext drawGeometry:self.middleCylinder];
}

和之前唯一不同的是 [glContext drawGeometry:self.topCircle];方法,这个是新增的用于绘制GLGeometry的方法。实现如下:

- (void)drawGeometry:(GLGeometry *)geometry {
    glBindBuffer(GL_ARRAY_BUFFER, [geometry getVBO]);
    [self bindAttribs:NULL];
    if (geometry.geometryType == GLGeometryTypeTriangleFan) {
        glDrawArrays(GL_TRIANGLE_FAN, 0, [geometry vertexCount]);
    } else if (geometry.geometryType == GLGeometryTypeTriangles) {
        glDrawArrays(GL_TRIANGLES, 0, [geometry vertexCount]);
    } else if (geometry.geometryType == GLGeometryTypeTriangleStrip) {
        glDrawArrays(GL_TRIANGLE_STRIP, 0, [geometry vertexCount]);
    }
}

主要就是根据不同的geometryType绘制vbo,很好理解。最后回到ViewController,利用三个圆柱体组装个锤子吧。

- (void)createCylinder {
    GLKTextureInfo *metal1 = [GLKTextureLoader textureWithCGImage:[UIImage imageNamed:@"metal_01.png"].CGImage options:nil error:nil];
    GLKTextureInfo *metal2 = [GLKTextureLoader textureWithCGImage:[UIImage imageNamed:@"metal_02.jpg"].CGImage options:nil error:nil];
    GLKTextureInfo *metal3 = [GLKTextureLoader textureWithCGImage:[UIImage imageNamed:@"metal_03.png"].CGImage options:nil error:nil];
    // 四边的圆柱体就是一个四方体
    Cylinder * cylinder = [[Cylinder alloc] initWithGLContext:self.glContext sides:4 radius:0.9 height:1.2 texture:metal1];
    cylinder.modelMatrix = GLKMatrix4MakeTranslation(0, 2, 0);
    [self.objects addObject:cylinder];
    
    Cylinder * cylinder2 = [[Cylinder alloc] initWithGLContext:self.glContext sides:16 radius:0.2 height:4.0 texture:metal3];
    [self.objects addObject:cylinder2];
    
    // 四边的圆柱体就是一个正方体
    Cylinder * cylinder3 = [[Cylinder alloc] initWithGLContext:self.glContext sides:4 radius:0.41 height:0.3 texture:metal2];
    cylinder3.modelMatrix = GLKMatrix4MakeTranslation(0, -2, 0);
    [self.objects addObject:cylinder3];
}

最终效果图如下。

本文通过绘制圆柱体来介绍使用代码生成基本几何体的思路和方法。下篇文章中将介绍如何使用一张地形图片生成一个复杂的地形模型,敬请期待。

Updated: