Unity: 从OpenGL角度再次理解DrawCall

从Unity层面较难真正理解DarwCall,从绘制接口层面会更清晰的认识到DrawCall.
以下是一段自动生成的gl代码,用来绘制一个texture:

#include <GL/glew.h>
#include <GLFW/glfw3.h>
#include <stb_image.h>
#include <iostream>

// 顶点着色器
const char* vertexShaderSource = R"(
#version 330 core
layout (location = 0) in vec3 aPos;
layout (location = 1) in vec2 aTexCoord;
out vec2 TexCoord;
uniform mat4 projection;
uniform mat4 view;
void main() {
    gl_Position = projection * view * vec4(aPos, 1.0);
    TexCoord = aTexCoord;
}
)";

// 片段着色器
const char* fragmentShaderSource = R"(
#version 330 core
out vec4 FragColor;
in vec2 TexCoord;
uniform sampler2D texture1;
void main() {
    FragColor = texture(texture1, TexCoord);
}
)";

// 纹理加载函数
GLuint LoadTexture(const char* path) {
    GLuint textureID;
    glGenTextures(1, &textureID);
    glBindTexture(GL_TEXTURE_2D, textureID);

    // 设置纹理参数
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);

    int width, height, nrChannels;
    unsigned char* data = stbi_load(path, &width, &height, &nrChannels, 0);
    if (data) {
        GLenum format = (nrChannels == 3) ? GL_RGB : GL_RGBA;
        glTexImage2D(GL_TEXTURE_2D, 0, format, width, height, 0, format, GL_UNSIGNED_BYTE, data);
        glGenerateMipmap(GL_TEXTURE_2D);
    } else {
        std::cout << "Failed to load texture" << std::endl;
    }
    stbi_image_free(data);

    return textureID;
}

int main() {
    // 初始化GLFW
    if (!glfwInit()) {
        std::cout << "GLFW initialization failed!" << std::endl;
        return -1;
    }

    // 创建窗口
    GLFWwindow* window = glfwCreateWindow(800, 600, "Simple Texture Example", nullptr, nullptr);
    if (!window) {
        std::cout << "GLFW window creation failed!" << std::endl;
        glfwTerminate();
        return -1;
    }

    glfwMakeContextCurrent(window);
    glfwSetFramebufferSizeCallback(window, [](GLFWwindow* window, int width, int height) {
        glViewport(0, 0, width, height);
    });

    // 初始化GLEW
    if (glewInit() != GLEW_OK) {
        std::cout << "GLEW initialization failed!" << std::endl;
        return -1;
    }

    // 定义四边形的顶点和纹理坐标
    float vertices[] = {
        // 顶点位置          // 纹理坐标
        -0.5f, -0.5f, 0.0f,  0.0f, 0.0f,  // 左下角
         0.5f, -0.5f, 0.0f,  1.0f, 0.0f,  // 右下角
         0.5f,  0.5f, 0.0f,  1.0f, 1.0f,  // 右上角
        -0.5f,  0.5f, 0.0f,  0.0f, 1.0f   // 左上角
    };

    // 定义索引
    unsigned int indices[] = {
        0, 1, 2, 2, 3, 0  // 两个三角形
    };

    // 创建VBO、VAO、EBO
    GLuint VAO, VBO, EBO;
    glGenVertexArrays(1, &VAO);
    glGenBuffers(1, &VBO);
    glGenBuffers(1, &EBO);

    glBindVertexArray(VAO);

    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, EBO);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW);

    // 设置顶点属性
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);
    glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 5 * sizeof(float), (void*)(3 * sizeof(float)));
    glEnableVertexAttribArray(1);

    // 编译着色器
    GLuint vertexShader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertexShader, 1, &vertexShaderSource, nullptr);
    glCompileShader(vertexShader);

    GLuint fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, nullptr);
    glCompileShader(fragmentShader);

    GLuint shaderProgram = glCreateProgram();
    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragmentShader);
    glLinkProgram(shaderProgram);

    // 删除着色器,因为它们已经被链接
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);

    // 加载纹理
    GLuint texture = LoadTexture("path_to_your_texture.jpg"); // 更换为你的纹理路径

    // 设置投影矩阵和观察矩阵
    GLuint projectionLoc = glGetUniformLocation(shaderProgram, "projection");
    GLuint viewLoc = glGetUniformLocation(shaderProgram, "view");
    glm::mat4 projection = glm::perspective(glm::radians(45.0f), 800.0f / 600.0f, 0.1f, 100.0f);
    glm::mat4 view = glm::translate(glm::mat4(1.0f), glm::vec3(0.0f, 0.0f, -3.0f));
    // 设置矩阵等 uniform 变量
    glUseProgram(shaderProgram);
    glUniformMatrix4fv(projectionLoc, 1, GL_FALSE, &projection[0][0]);
    glUniformMatrix4fv(viewLoc, 1, GL_FALSE, &view[0][0]);

    // 渲染循环
    while (!glfwWindowShouldClose(window)) {
        glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

        // 使用着色器程序
        glUseProgram(shaderProgram);

        // 绑定纹理
        glActiveTexture(GL_TEXTURE0);
        glBindTexture(GL_TEXTURE_2D, texture);
        glUniform1i(glGetUniformLocation(shaderProgram, "texture1"), 0);

        // 绘制四边形
        glBindVertexArray(VAO);
        glDrawElements(GL_TRIANGLES, 6, GL_UNSIGNED_INT, 0);

        // 交换缓冲区并查询事件
        glfwSwapBuffers(window);
        glfwPollEvents();
    }

    // 清理
    glDeleteVertexArrays(1, &VAO);
    glDeleteBuffers(1, &VBO);
    glDeleteBuffers(1, &EBO);
    glfwTerminate();

    return 0;
}

GL的每种操作都是单一的,命令式的,这个特性也就导致哪怕进行简单的操作,也需要大量的命令式调用才能完成,即需要通知GPU设置当前为某个状态或者某个环境(比如指明使用某个缓冲区),再指明其操作.

可以清晰的看到,分为几个步骤:

  1. gl 环境检查和准备
  2. 数据的定义(产生)
  3. gl buffer数据的开辟和绑定: glGenBuffers(),glBindBuffer(), glBufferData()等
  4. gl 着色器的编译,glCompileShader(), glLinkProgram()等
  5. gl 矩阵变换所需的数据设置
  6. 循环绘制, glDrawElements(), glUseProgram(), glActiveTexture()等
  7. 退出时的数据清除

性能消耗分析

  • 第1步: 基本是固定格式,gl驱动固定消耗
  • 第2步: 业务传入,集中在CPU开销,所以某些数据可以通过离线烘焙来得到这些数据,避免实时计算.这也是动态合批要求比较严格,因为太多会加重CPU的计算开销.
  • 第3步: 数据绑定,这个数据绑定就是将已经计算好的各种顶点数据传输到显存中.对于统一内存架构(集显)UMA,这块内存一般是共享的,对于非同一内存架构(独显)NUMA,这块数据就走PCIe总线拷贝传输.除了通过``glMapBuffer()```这种内存映射形式"避免拷贝",但
  1. 如果一次提交太多数据,本身就有一定性能开销,虽然在统一内存架构中可以通过直接内存访问(DMA)可以直接操作这块内存,但提交的数据越多,这个内存的开辟必然更大,DMA的同步管理也会消耗,同时GPU也会等待数据的提交完成.
  2. 使用glBufferSubData(),glMapBufferRange()等可以只操作变动的那些区域(字节)
  3. 频繁的修改这些数据,会造成多次提交,所以汇总一次提交是更好的结果.
  • 第4步,着色器编译,这个一看就知道消耗性能,离线编译肯定是能解决这个问题的,通过glProgramBinary()来直接将离线编译的shader结果进行加载,避免即时编译.
  • 第5步,设置变换数据,这是在为shader设置相关数据.业务量决定.
  • 第6步: glDrawXX使得GPU开始真正渲染,就是渲染管线那套流程,顶点,光栅化,片元,ZTest,混合等.
  1. glDrawXX是有一定性能开销的,虽然由于经过了高度优化,且是GPU的并行执行,但如果有比较重的片元着色器执行逻辑还是会产生较多开销,但不是说这个函数就承担了绝大部分开销.
  2. 同时还有glUseProgram(),glBindTexture()等设置当前渲染状态的操作,即"状态切换",当绘制大量不同物体时,shader,texture等的设置(切换)必然较多,每次切换都有一定开销,所以减少这些切换操作也就越能提升性能(即利用当前的已经上传了的数据缓存),主要是各个缓冲区的变更导致CPU的数据上传和GPU的切换工作有消耗. SRP Batcher就是着重优化的这些切换操作频次.
  • 第7步: 一般是退出或重启才有,忽略不计.

现: 假设有2个模型,使用了同一份材质(相同纹理贴图,相同shader),在关闭掉Unity的自动合批等优化行为后,为什么会产生2个DrawCall:

  • 因为在绘制这2个模型的时候,要分别指定2次这些模型的顶点数据,再调用glDrawElements(),才能将每一个模型都绘制出来. 不同的纹理也是类似,需要先切换纹理状态,再绘制.
  • 开启动态合批后,这2个模型的顶点数据在CPU侧已经合并了,所以只需要指定1次就可以同时绘制出这2个模型,只不过需要在CPU侧进行计算.

Unity:

  1. Unity动态合批: 可以减少DrawCall,相当于将满足条件(将材质相同,模型数量达到相关要求)的不同模型进行临时合并成一个大的结果(得到这些模型的顶点和偏移等),然后进行一次提交一次绘制. 牵涉到CPU计算,Unity设置了较为严格的门槛. Dynamic batching - Unity 手册.
  2. Unity的静态合批: 不能减少DrawCall(指该合并的合并后). 静态批处理 - Unity 手册
  • 虽然DC没有少,但速度快的原因是减少了切换,因为这些静态物体的数据是不变的,在第一次将数据提交到GPU并绑定后就不变了,就少了一些数据提交的步骤,开销自然降低一些.
  1. GPUInstance: 通过glVertexAttribPointer(), glVertexAttribDivisor(), glEnableVertexAttribArray()等 去"配置"和"查找"不同纹理和不同模型的关联,然后再执行glDrawElementsInstanced()进行一次DC提交. 很明显,GPUInstance通过DC前的"装配"这个操作来达到只执行一次的目的.
  2. SetPass Call: 是一个概念上的统称, 上述的glUseProgram(),glBindTexture()的每个函数调用1次,都称做1次SetPass Call,包含了"状态切换"操作.
  3. SRP Batcher: 就是将那些可以合并的SetPass Call通过批量合并相同的材质对象的顶点数据,并将它们打包为一个缓冲区(CBuffer),这样就相当于多个材质相同的对象只进行少量的SetPass Call(毕竟都打包在一起了),然后再使用顶点偏移来绘制不同的对象,但不会减少DrawCall,因为每个对象的绘制是需要调用glDrawXX的. 这些数据通常是uniform类型(UBO),通过glBindBufferBase(GL_UNIFORM_BUFFER,xxx)来完成绑定的.
  • 比如要显示2个模型,传统做法是绑定模型1数据,DC1,绑定模型2数据,DC2,是4步操作,2个SetPass,2个DC. 现在变为先将模型1,模型2数据合并到一个CBuffer中,然后绑定这个CBuffer,DC1,DC2,是3步操作,1个SetPass,2个DC.
  • SRP Batcher的打断依据是shader是否一致,不一致就会被打断,通过上述流程很容易看到: 在执行DC之前需要glUseProgram(),不同的shader自然会导致重新调用glUseProgram()(不然就是错误的应用了shader),重新设置也就自然必须要把那些不同shader的SetPass操作放到不同的CBuffer中,就是所谓的"打断".
  • SRP是要在shader中配合使用的,就是为了配合传入在gl之外传入的内容. 所以Unity Shader中需要使用CBUFFER_START(xx)来确定那些内容是对应外部传入的的数据的.
  • 很显然Unity Shader中定义的多Pass会导致打断,因为多Pass在本质上是要绘制多次的,信息也有所不同,相当与每次的shader不同,自然被打断.

多Pass
OpenGL的shader代码中是没有Pass这种语句块的,是unity shader为了方便代码书写和管理进行的处理,通过解析后,由unity调用OpenGL中的相关原生函数来完成的.


上述步骤很容易模式化,引擎在内部会将上述步骤进行若干拆分和组合,最终形成了操作管线(可编程管线). 通过上述内容,再扩展理解贴图,光照贴图,shader,多pass等会更清晰,然后再配合理解GPU侧的渲染管线中的各个步骤:
[从主存中读取顶点信息] > [Position Shading坐标处理] > [Facing Test Culing背面三角形剔除] > [Frustum Test Culling摄像机平截头体外三角形剔除] > [Sample Test Culling无法分辨的极小三角形剔除] > 顶点着色 > (裁剪空间) > [生成 多边形处理结果列表,形成Tile List,写入显存] > 曲面细分... > 几何着色... > [从显存中读取Tile List] > 光栅化 > [ZTest(Early-Z)] > [ZWrite] > 片元着色 > (屏幕空间) > [Alpha Test(Discard)] > [Stencil Test] > [ZTest] > [Blender混合颜色] > [分别写入最终颜色到Tile List中的块信息中] > [Transaction Elimination和现有FrameBuffer比较,将变动的Tile块重新写入显存]

ARM Mobile Studio性能优化(二) - 技术专栏 - Unity官方开发者社区

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容