基于OpenGL编写一个简易的2D渲染框架-11 重构渲染器-Renderer

假如要渲染一个纯色矩形在窗口上,应该怎么做?

先确定顶点的格式,一个顶点应该包含位置信息 vec3 以及颜色信息 vec4,所以顶点的结构体定义可以这样:

struct Vertex
{
     Vec3 position;
     Vec4 color;
};

然后填充矩形四个顶点是数据信息:

        Vertex* data = ( Vertex* ) malloc(sizeof( Vertex ) * 4);
        data[0].position.set(0, 0, 0);
        data[1].position.set(0, 0, 0);
        data[2].position.set(0, 0, 0);
        data[3].position.set(0, 0, 0);

        data[0].color.set(1, 1, 1, 1);
        data[1].color.set(1, 1, 1, 1);
        data[2].color.set(1, 1, 1, 1);
        data[3].color.set(1, 1, 1, 1);

分配一块内存,将内存类型转换为 Vertex,最后设置数据。上面只是用了4个顶点,显然还要设置索引数据:

        int* indices = ( int* ) malloc(sizeof( int ) * 6);
        indices[0] = 0;
        indices[1] = 2;
        indices[2] = 1;
        indices[3] = 0;
        indices[4] = 3;
        indices[5] = 2;

有了数据之后,需要设置顶点属性指针(这里没有使用 VBO 和 VAO):

        glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, sizeof(Vertex), ( char* ) data + sizeof( float ) * 0);
        glVertexAttribPointer(1, 4, GL_FLOAT, GL_FALSE, sizeof(Vertex), ( char* ) data + sizeof( float ) * 3);
        glEnableVertexAttribArray(0);
        glEnableVertexAttribArray(1);

最后,使用着色程序,调用 glDrawElements 函数进行绘制:

        glUseProgram(shaderProgram);
        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, indices);

就这样,一次绘制的流程就结束了,这次渲染器就是按照上面的步骤展开。上面只是绘制了一个矩形,如果要绘制上百个矩形和上千张图片,并且它们的渲染管线的状态各不相同,还要保持一定的效率,管理这些顶点可不是一个简单的问题。

其中的一个难点就是如何分配顶点的内存,如果每渲染一个图元就要调用一次 malloc 和 free 函数,这样会带来很大的开销。或许我们可以在开始的时候就分配一大块内存,绘制时统统把顶点数据复制到这块内存,然后通过起始索引来绘制指定范围内的顶点,前面的第一种渲染器的设计方式用的就是这种。

这样做就会带来大量的 drawcall(对 OpenGL 来说绘制参数(状态值)的变更要比绘制大量的顶点更耗费 CPU),绘制效率有所下降。想要更高的性能,通过批次合并(即将合理的方式将渲染状态相同多个可渲染物的 draw 绘制数据合并到一批绘制)可以降低开销,但这样批次合并的顶点数据必须连续储存在一起。那么这样就不能简单的分配一大块内存来储存这些顶点数据了。

那么有没有好的方法合理管理顶点内存?我给出的一个答案是 BlockAllocator(来自于 Box2D 库的 SOA 内存管理方案),它的原理是分配一大块内存 chunk,然后将 chunk 切割成许多的小块内存 block(block 的大小根据需要合理设置),block 通过链表连接起来。但申请一块内存的时候不再使用 malloc 函数,而是 BlockAllocator 直接返回一块合适大小的 block,用完后返还给 BlockAllocator 即可,这个过程只有非常少量的 malloc,可以说效率很高。关于 BlockAllocator 的详细解析,将在下一篇文章中给出。

Renderer 的实现

Renderer 有两个 Pass 列表,分别是不透明 Pass 列表和半透明 Pass 列表:

typedef std::map<Pass*, RenderOperation, PassSort> PassVertexIndexMap;
/* 不透明列表 */
PassVertexIndexMap solidVertexIndexMap;
/* 透明列表 */
PassVertexIndexMap transparentVertexIndexMap;

在渲染器中,在插入 Pass 时可以对 Pass 进行排序,定义 Map 容器时设置了一个规定排序规则的仿函数 PassSort:

        struct PassSort
        {
            bool operator()(Pass* a, Pass* b) const
            {
                if ( (*a) < (*b) ) return true;
                return false;
            }
        };

每个 Pass 对象都有一个对应的渲染操作:

    struct RenderOperation
    {
        int    stride;

        int vertexCount;
        int indexCount;
        std::vector<VertexIndexData*>* vidVector;
    };

Pass 所对应的绘制数据都储存在 vidVector 数组中,对于半透明图元的渲染,会经过这个数组的排序,就能确保安顺序渲染这些半透明的图元了。实际上,使用 VertexIndexData 数组储存顶点数据就可以了。但是,VertexIndexData 也被定义为链表的一个结点,VertexIndexData 之间可以通过链表链接起来,用链表链接起来的 VertexIndexData 间不会进行排序,这是为粒子系统的渲染做的特别优化。

VertexIndexData 是一个存储顶点数据的结构:

    struct VertexIndexData
    {
        char* vertexData;            /* 顶点数据指针 */
        unsigned int*  indexData;    /* 顶点索引数据指针 */
        int vertexCount;             /* 顶点数量 */
        int indexCount;              /* 顶点索引数量 */    

        int index;                   /* 渲染排序索引 */
        VertexIndexData* next;
    };

这里只有一个指向顶点数据内存的指针,渲染器并不知道顶点数据的格式是怎样的,只需知道每一个顶点数据的跨度(RenderOperation 的 stride)即可。

如何通过渲染器渲染渲染顶点数据呢?首先设置渲染器当前的 Pass:

    void Renderer::setPass(Pass* pass, bool transparent)
    {
        if ( pCurrentPass != nullptr && pCurrentPass->equal(pass) ) return;
        pCurrentPass = pass;

        PassVertexIndexMap& map = transparent ? transparentVertexIndexMap : solidVertexIndexMap;
        auto it = map.find(pCurrentPass);
        if ( it == map.end() ) {
            it = map.insert(PassVertexIndexMap::value_type(pCurrentPass, RenderOperation())).first;
            it->second.vidVector = new std::vector<VertexIndexData*>();
            it->second.vertexCount = 0;
            it->second.indexCount = 0;
        }
        pCurrentRenderOperation = &it->second;
    }

渲染器会根据 transparent 这个变量确定 Pass 是被添加到不透明表 solidVertexIndexMap 还是半透明表 transparentVertexIndexMap,然后查找 Pass 表,存在 Pass 的话就记录下 Pass 对应的 RenderOperation。渲染器是通过 Pass 对象合并顶点数据的,所以在开发游戏的时候可以把许多小图合并成一张大图,一张纹理对着一个 Pass,也就意味着一个 drawcall,这样会减低开销。

设置好渲染器的 Pass 后,就可以设置顶点数据到渲染器,渲染器会管理这些数据:

    void Renderer::addVertexIndexData(VertexIndexData* viData, int stride, int depth)
    {
        VertexIndexData* sp = viData;
        while ( sp ) {

            /* 顶点变换 */
            for ( int i = 0; i < sp->vertexCount; i++ ) {
                Vec3* pos = ( Vec3* ) (( char* ) sp->vertexData + i * stride);
                *pos = mTransformMatrix * (*pos);
            }
            pCurrentRenderOperation->vertexCount += sp->vertexCount;
            pCurrentRenderOperation->indexCount += sp->indexCount;

            sp = sp->next;
        }

        /* 设置排序索引 */
        int size = pCurrentRenderOperation->vidVector->size();
        viData->index = (depth == -1) ? -size : depth;

        /* 添加顶点数据 */
        pCurrentRenderOperation->vidVector->push_back(viData);
        pCurrentRenderOperation->stride = stride;
    }

这里,我把顶点变换的操作放在了这里,而不是放到顶点着色器中。传进来的顶点数据会添加到 RenderOperation 的 VertexIndexData 数组中,并记录当前 RenderOperation 的顶点数量的索引数量。

渲染器的渲染

调用 render 函数即可完成渲染操作:

    void Renderer::render()
    {
        if ( solidVertexIndexMap.empty() == false ) {
            this->renderPassVertexIndexMap(solidVertexIndexMap);
        }
        if ( transparentVertexIndexMap.empty() == false ) {
            this->renderPassVertexIndexMap(transparentVertexIndexMap);
        }
    }

先渲染不透明的 Pass 列表,再渲染半透明的 Pass 列表。

    void Renderer::renderPassVertexIndexMap(PassVertexIndexMap& map)
    {
        for ( PassVertexIndexMap::iterator pass_it = map.begin(); pass_it != map.end(); ++pass_it ) {
            Pass* pass = pass_it->first;
            pass->setOpenGLState();

            RenderOperation* ro = &pass_it->second;

            std::sort(ro->vidVector->begin(), ro->vidVector->end(), compare);
            this->doRenderOperation(&pass_it->second, pass);

            delete ro->vidVector;
        }
        map.clear();
    }

函数中,迭代 Pass 列表,先调用 Pass 的 setOpenGLState 函数实现 OpenGL 状态的设置。然后会顶点数据进行排序(这里不透明列表本不需要排序的,这里设计成了不透明列表也进行排序),这里使用 std::sort 函数对 std::vector 的数据排序,最后一个参数是自定义的排序函数:

        static bool compare(VertexIndexData* a, VertexIndexData* b)
        {
            return a->index < b->index;
        }

最后在函数 doRenderOperation 中完成顶点数据的绘制。

    void Renderer::doRenderOperation(RenderOperation* ro, Pass* pass)
    {
        int vertexBytes = ro->vertexCount * ro->stride;
        int indexBytes = ro->indexCount * sizeof(int);

        assert(vertexBytes <= 512000 && indexBytes <= 102400);

        int vertexIndex = 0, indexIndex = 0;
        for ( auto& ele : *(ro->vidVector) ) {
            VertexIndexData* sp = ele;

            while ( sp ) {
                /* 拷贝顶点索引数据 */
                for ( int i = 0; i < sp->indexCount; i++ ) {
                    indexBuffer[indexIndex++] = vertexIndex + sp->indexData[i];
                }
                /* 拷贝顶点数据 */
                memcpy(vertexBuffer + vertexIndex * ro->stride, sp->vertexData, sp->vertexCount * ro->stride);
                vertexIndex += sp->vertexCount;

                /* 释放资源 */
                pBlockAllocator->free(sp->vertexData);
                pBlockAllocator->free(sp->indexData);
                pBlockAllocator->free(sp);
                sp = sp->next;
            }
        }

        Shader* shader = pass->getShader();
        shader->bindProgram();
        shader->bindVertexDataToGPU(vertexBuffer);

        /* 绘制 */
        glDrawElements(pass->getPrimType(), ro->indexCount, GL_UNSIGNED_INT, indexBuffer);
        nDrawcall++;
    }

函数中对 VertexIndexData 数组的数据进行一次合并,储存在一块内存中:

        char vertexBuffer[1024 * 500];      /* 用于合并顶点的缓冲区 */
        unsigned indexBuffer[25600];        /* 用于合并索引的缓冲区 */

在绑定好着色程序(同时绑定 Uniform 数据)和设置顶点属性指针后,调用 glDrawElements 函数进行绘制,完成一次渲染通路的渲染。整个渲染器的设计就结束了,和前一个渲染器相比,代码结构清晰了许多。后续会使用 Simple2D 开发一个游戏,并逐步完善渲染器。

源码下载:http://pan.baidu.com/s/1skOmP21

时间: 2024-07-31 14:25:49

基于OpenGL编写一个简易的2D渲染框架-11 重构渲染器-Renderer的相关文章

基于OpenGL编写一个简易的2D渲染框架-05 渲染文本

阅读文章前需要了解的知识:文本渲染 https://learnopengl-cn.github.io/06%20In%20Practice/02%20Text%20Rendering/ 简要步骤: 获取要绘制的字符的 Unicode 码,使用 FreeType 库获取对应的位图数据,添加到字符表中(后面同样的字符可以再表中直接索引),将字符表上的字符填充到一张纹理上.计算每个字符的纹理坐标,使用渲染器绘制 注意的问题: 对于中英文混合的字符串,使用 char 存储时,英文字符占 1 个字节,而中

基于OpenGL编写一个简易的2D渲染框架01——创建窗口

最近正在学习OpenGL,我认为学习的最快方法就是做一个小项目了. 如果对OpenGL感兴趣的话,这里推荐一个很好的学习网站 https://learnopengl-cn.github.io/ 我用的是 vs2013,使用C++语言编写项目.这个小项目叫Simple2D,意味着简易的2D框架.最终的目的是可以渲染几何图形和图片,最后尝试加上一个2D粒子系统和Box2D物理引擎,并编译一个简单的游戏. 第一步,就是创建一个Win32项目. 接下来,生成一个窗口.编写一个RenderWindow类,

基于OpenGL编写一个简易的2D渲染框架-08 重构渲染器-整体架构

事实上,前面编写的渲染器 Renderer 非常简陋,虽然能够进行一些简单的渲染,但是它并不能满足我们的要求. 当渲染粒子系统时,需要开启混合模式,但渲染其他顶点时却不需要开启混合模式.所以同时渲染粒子系统和其他纹理时会得不到想要的结果,渲染器还存在许多的不足: 1.当渲染许多透明图形时,没有对其进行排序,使得本应透明的图形没有透明. 2.不能对不同的顶点使用不同的状态进行渲染. 渲染器要做的东西很简单,就是 1.传递数据到 GPU 2.设置 OpenGL 状态信息(Alpha测试.模板测试.深

基于OpenGL编写一个简易的2D渲染框架-04 绘制图片

阅读文章前需要了解的知识,纹理:https://learnopengl-cn.github.io/01%20Getting%20started/06%20Textures/ 过程简述:利用 FreeImage 库加载图像数据,再创建 OpenGL 纹理,通过 Canvas2D 画布绘制,最后又 Renderer 渲染器渲染 本来想用 soil 库加载图像数据的,虽然方便,但是加载有些格式的图像文件时会出现一些问题.最后,改用 FreeImage 库来加载图像了. 添加 FreeImage 库到工

基于OpenGL编写一个简易的2D渲染框架02——搭建OpenGL环境

由于没有使用GLFW库,接下来得费一番功夫. 阅读这篇文章前请看一下这个网页:https://learnopengl-cn.github.io/01%20Getting%20started/02%20Creating%20a%20window/ 以下,我摘取了一点片段 Windows上的OpenGL库 如果你是Windows平台,opengl32.lib已经包含在Microsoft SDK里了,它在Visual Studio安装的时候就默认安装了.由于这篇教程用的是VS编译器,并且是在Windo

基于OpenGL编写一个简易的2D渲染框架-13 使用例子

这是重构渲染器的最后一部分了,将会给出一个 demo,测试模板测试.裁剪测试.半透明排序等等: 上图是本次 demo 的效果图,中间的绿色图形展现的是模板测试. 模板测试 void init(Pass*& p1, Pass*& p2) { p1 = new Pass; p2 = new Pass; Shader* s1 = new Shader("Shader/defaultGeometryShader.vs", "Shader/defaultGeometry

基于OpenGL编写一个简易的2D渲染框架-07 鼠标事件和键盘事件

这次为程序添加鼠标事件和键盘事件 当检测到鼠标事件和键盘事件的信息时,捕获其信息并将信息传送到需要信息的对象处理.为此,需要一个可以分派信息的对象,这个对象能够正确的把信息交到正确的对象. 实现思路: 要实现以上的功能,需要几个对象: 事件分派器:EventDispatcher,负责将 BaseEvent 分派给 EventListener 对象 事件监听器:EventListener,这只是一个接口类,接受 BaseEvent 的对象,真正的处理在它的子类中实现 事件:BaseEvent,储存

基于OpenGL编写一个简易的2D渲染框架-09 重构渲染器-Shader

Shader 只是进行一些简单的封装,主要功能: 1.编译着色程序 2.绑定 Uniform 数据 3.根据着色程序的顶点属性传递顶点数据到 GPU 着色程序的编译 GLuint Shader::createShaderProgram(const char* vsname, const char* psname) { std::string vShaderSource, fShaderSource; std::ifstream vShaderFile, fShaderFile; vShaderF

基于OpenGL编写一个简易的2D渲染框架-12 重构渲染器-BlockAllocator

BlockAllocator 的内存管理情况可以用下图表示 整体思路是,先分配一大块内存 Chunk,然后将 Chunk 分割成小块 Block.由于 Block 是链表的一个结点,所以可以通过链表的形式把未使用的 Block 连接起来,并保存到 pFreeLists 中.当我们向 BlockAllocator 申请一块内存时,BlockAllocator 会通过 pFreeLists 表索引出一块空闲的 Block,并返回其地址.当我们不断申请内存的时候,BlockAllocator 会不断返