从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设置当前为某个状态或者某个环境(比如指明使用某个缓冲区),再指明其操作.
可以清晰的看到,分为几个步骤:
- gl 环境检查和准备
- 数据的定义(产生)
- gl buffer数据的开辟和绑定:
glGenBuffers(),glBindBuffer(), glBufferData()等
- gl 着色器的编译,
glCompileShader(), glLinkProgram()等
- gl 矩阵变换所需的数据设置
-
循环绘制,
glDrawElements(), glUseProgram(), glActiveTexture()等
- 退出时的数据清除
性能消耗分析
- 第1步: 基本是固定格式,gl驱动固定消耗
- 第2步: 业务传入,集中在CPU开销,所以某些数据可以通过离线烘焙来得到这些数据,避免实时计算.这也是动态合批要求比较严格,因为太多会加重CPU的计算开销.
- 第3步: 数据绑定,这个数据绑定就是将已经计算好的各种顶点数据传输到显存中.对于统一内存架构(集显)UMA,这块内存一般是共享的,对于非同一内存架构(独显)NUMA,这块数据就走PCIe总线拷贝传输.除了通过``glMapBuffer()```这种内存映射形式"避免拷贝",但
- 如果一次提交太多数据,本身就有一定性能开销,虽然在统一内存架构中可以通过直接内存访问(DMA)可以直接操作这块内存,但提交的数据越多,这个内存的开辟必然更大,DMA的同步管理也会消耗,同时GPU也会等待数据的提交完成.
- 使用
glBufferSubData(),glMapBufferRange()
等可以只操作变动的那些区域(字节) - 频繁的修改这些数据,会造成多次提交,所以汇总一次提交是更好的结果.
- 第4步,着色器编译,这个一看就知道消耗性能,离线编译肯定是能解决这个问题的,通过
glProgramBinary()
来直接将离线编译的shader结果进行加载,避免即时编译. - 第5步,设置变换数据,这是在为shader设置相关数据.业务量决定.
- 第6步:
glDrawXX
使得GPU开始真正渲染,就是渲染管线那套流程,顶点,光栅化,片元,ZTest,混合等.
-
glDrawXX
是有一定性能开销的,虽然由于经过了高度优化,且是GPU的并行执行,但如果有比较重的片元着色器执行逻辑还是会产生较多开销,但不是说这个函数就承担了绝大部分开销. - 同时还有
glUseProgram(),glBindTexture()
等设置当前渲染状态的操作,即"状态切换",当绘制大量不同物体时,shader,texture等的设置(切换)必然较多,每次切换都有一定开销,所以减少这些切换操作也就越能提升性能(即利用当前的已经上传了的数据缓存),主要是各个缓冲区的变更导致CPU的数据上传和GPU的切换工作有消耗. SRP Batcher就是着重优化的这些切换操作频次.
- 第7步: 一般是退出或重启才有,忽略不计.
现: 假设有2个模型,使用了同一份材质(相同纹理贴图,相同shader),在关闭掉Unity的自动合批等优化行为后,为什么会产生2个DrawCall:
- 因为在绘制这2个模型的时候,要分别指定2次这些模型的顶点数据,再调用glDrawElements(),才能将每一个模型都绘制出来. 不同的纹理也是类似,需要先切换纹理状态,再绘制.
- 开启动态合批后,这2个模型的顶点数据在CPU侧已经合并了,所以只需要指定1次就可以同时绘制出这2个模型,只不过需要在CPU侧进行计算.
Unity:
- Unity动态合批: 可以减少DrawCall,相当于将满足条件(将材质相同,模型数量达到相关要求)的不同模型进行临时合并成一个大的结果(得到这些模型的顶点和偏移等),然后进行一次提交一次绘制. 牵涉到CPU计算,Unity设置了较为严格的门槛. Dynamic batching - Unity 手册.
- Unity的静态合批: 不能减少DrawCall(指该合并的合并后). 静态批处理 - Unity 手册
- 虽然DC没有少,但速度快的原因是减少了切换,因为这些静态物体的数据是不变的,在第一次将数据提交到GPU并绑定后就不变了,就少了一些数据提交的步骤,开销自然降低一些.
- GPUInstance: 通过
glVertexAttribPointer(), glVertexAttribDivisor(), glEnableVertexAttribArray()等
去"配置"和"查找"不同纹理和不同模型的关联,然后再执行glDrawElementsInstanced()
进行一次DC提交. 很明显,GPUInstance通过DC前的"装配"这个操作来达到只执行一次的目的. - SetPass Call: 是一个概念上的统称, 上述的
glUseProgram(),glBindTexture()
的每个函数调用1次,都称做1次SetPass Call,包含了"状态切换"操作. - 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块重新写入显存]