文章目录

OpenGL 多重采样抗锯齿(MSAA)简介
抗锯齿技术的目标是减少或消除图形渲染中常见的锯齿效应(Alias)。锯齿通常出现在渲染斜线或曲线时,因为显示设备的像素网格限制了细节的精度。多重采样抗锯齿(MSAA,Multi-Sample Anti-Aliasing)是一种通过增加每个像素的采样数来减少锯齿的技术。
在多重采样中,OpenGL 并不是在单一的像素位置进行采样,而是对每个像素的多个子位置进行采样,计算它们的平均值。这种方法通常可以大大减少锯齿效果,并且在性能开销上比其他更复杂的抗锯齿方法(如超级采样抗锯齿,SSAA)要高效得多。
为什么需要抗锯齿?
渲染时,图形的边缘经常显示出“阶梯状”的锯齿效果。这种现象特别明显在显示斜线或曲线时,像素排列的离散性导致边缘无法平滑过渡。因此,抗锯齿方法通过对像素进行更多的采样,尝试重建更平滑的图像边缘,消除这些不自然的阶梯状效果。
多重采样抗锯齿的工作原理
与传统的单次采样不同,MSAA 通过对每个像素进行多个采样来减少锯齿现象。具体而言,MSAA 会在每个像素内进行多个子像素采样,这些采样点的位置不会完全重合,而是分布在像素的不同位置。MSAA 通过对这些子样本的颜色值进行平均,得出最终的像素颜色,从而减少锯齿现象。
对于每个像素来说,越少的子采样点被三角形所覆盖,那么它受到三角形的影响就越小。三角形的不平滑边缘被稍浅的颜色所包围后,从远处观察时就会显得更加平滑了。
MSAA 的工作流程
MSAA 的工作流程可以分为几个关键步骤:
1. 创建多重采样缓冲区
在 OpenGL 中,首先要创建一个支持多重采样的帧缓冲对象(Framebuffer Object,FBO),并为每个像素配置多个样本。这要求显卡在进行像素处理时能够处理多个子像素级别的颜色、深度和模板数据。
2. 渲染场景到多重采样缓冲区
渲染过程会在这个多重采样的缓冲区中执行,每个像素会有多个采样点,OpenGL 将计算这些采样点的颜色和深度。
3. 将多重采样结果合并到单一帧缓冲
渲染完成后,OpenGL 会将每个像素的多个采样结果进行合并,通常是通过计算这些采样点的平均值来实现。最终的合成结果是一个视觉上更加平滑、没有明显锯齿的图像。
离屏渲染示例代码
在 OpenGL 中使用多重采样时,通常会先进行离屏渲染(即将场景渲染到一个不可见的帧缓冲区),然后再将渲染结果输出到屏幕。以下是一个离屏渲染的伪代码示例,展示了如何在 OpenGL 中设置和使用多重采样。
伪代码(离屏渲染模式)
// 1. 初始化 OpenGL 和创建窗口
glfwInit();
glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
glfwWindowHint(GLFW_SAMPLES, 4); // 启用 4 倍多重采样
GLFWwindow* window = glfwCreateWindow(800, 600, "OpenGL MSAA", NULL, NULL);
glfwMakeContextCurrent(window);
glewExperimental = GL_TRUE;
glewInit();
// 2. 创建多重采样帧缓冲对象(FBO)和渲染缓冲
GLuint msaaFBO;
glGenFramebuffers(1, &msaaFBO);
glBindFramebuffer(GL_FRAMEBUFFER, msaaFBO);
// 创建一个多重采样的渲染缓冲对象用于颜色附件
GLuint msaaColorBuffer;
glGenRenderbuffers(1, &msaaColorBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, msaaColorBuffer);
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_RGB8, 800, 600); // 4 个样本
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, msaaColorBuffer);
// 创建多重采样的渲染缓冲对象用于深度和模板附件
GLuint msaaDepthBuffer;
glGenRenderbuffers(1, &msaaDepthBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, msaaDepthBuffer);
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, 800, 600); // 4 个样本
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, msaaDepthBuffer);
// 检查帧缓冲是否完整
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
// 处理错误
}
// 3. 创建用于解析的中间帧缓冲对象(普通帧缓冲)
GLuint intermediateFBO;
glGenFramebuffers(1, &intermediateFBO);
glBindFramebuffer(GL_FRAMEBUFFER, intermediateFBO);
// 创建一个纹理附件用于存储解析后的图像
GLuint screenTexture;
glGenTextures(1, &screenTexture);
glBindTexture(GL_TEXTURE_2D, screenTexture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGB8, 800, 600, 0, GL_RGB, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, screenTexture, 0);
// 检查帧缓冲是否完整
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
// 处理错误
}
// 4. 渲染场景到多重采样帧缓冲
glBindFramebuffer(GL_FRAMEBUFFER, msaaFBO);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); // 清除颜色和深度缓冲
// 渲染场景:例如,绘制模型、应用光照等
renderScene();
// 5. 解析多重采样帧缓冲的内容到中间帧缓冲
glBindFramebuffer(GL_READ_FRAMEBUFFER, msaaFBO);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, intermediateFBO);
glBlitFramebuffer(0, 0, 800, 600, 0, 0, 800, 600,
GL_COLOR_BUFFER_BIT, GL_NEAREST); // 使用邻近过滤
// 6. 将解析后的结果渲染到屏幕
glBindFramebuffer(GL_FRAMEBUFFER, 0); // 绑定默认帧缓冲
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);
// 渲染屏幕四边形,使用 screenTexture 作为纹理
renderQuad(screenTexture);
// 7. 交换缓冲区,展示最终结果
glfwSwapBuffers(window);
glfwPollEvents();
// 8. 清理资源并终止
glDeleteFramebuffers(1, &msaaFBO);
glDeleteFramebuffers(1, &intermediateFBO);
glDeleteRenderbuffers(1, &msaaColorBuffer);
glDeleteRenderbuffers(1, &msaaDepthBuffer);
glDeleteTextures(1, &screenTexture);
glfwTerminate();
MSAA 的优缺点
优点
- 抗锯齿效果明显:通过对每个像素进行多个采样,MSAA 能显著减少图像中的锯齿现象,特别是在渲染斜线或曲线时。
- 性能开销较低:相比于超级采样(SSAA),MSAA 提供了一个较为高效的解决方案,适用于大部分需要抗锯齿的应用。
缺点
- 性能损耗:即使 MSAA 比 SSAA 高效,但仍然比传统的单次采样渲染有一定的性能开销,尤其是在高采样率时(例如 8x 或 16x 多重采样)。
- 透明物体表现差:MSAA 在处理透明物体时效果较差,因为透明像素需要额外的处理来避免锯齿。
- 不能消除所有锯齿问题:对于着色器内的细节(如程序生成的高频纹理),MSAA 无法抗锯齿,因为这些细节并不受几何覆盖影响。
使用 MSAA 的场景
MSAA 常用于以下几种场景:
- 实时渲染:例如在游戏或虚拟现实应用中,需要平滑的边缘效果而不牺牲太多性能。
- 3D 建模和视觉效果:在需要渲染逼真图形时,MSAA 是一个重要的抗锯齿技术,特别是在光照和阴影效果复杂的场景中。
完整源码示例
下面的代码示例只需要opengl的库和opencv,采用离屏渲染
的方式展示多重采样抗锯齿的效果
#include <iostream>
#include <fstream>
#include <EGL/egl.h>
#include <EGL/eglext.h>
#include <GLES3/gl3.h>
#include "opencv2/opencv.hpp"
#if !(defined(__arm__) || defined(__aarch64__))
namespace
{
const char *vertex_shader_source = R"(
#version 310 es
precision mediump float;
layout(location = 0) in vec3 a_position;
layout(location = 1) in vec2 a_texCoord;
out vec2 v_texCoord;
void main()
{
gl_Position = vec4(a_position, 1.0);
v_texCoord = a_texCoord;
}
)";
const char *fragment_shader_source = R"(
#version 310 es
precision mediump float;
in vec2 v_texCoord;
out vec4 fragColor;
uniform sampler2D s_texture;
void main()
{
fragColor = texture(s_texture, v_texCoord);
// fragColor = vec4(fragColor.r, fragColor.g, fragColor.b, 1.0);
// fragColor = vec4(1.0, 0.20, 0.30, 1.0);
}
)";
class GraphicsContext
{
public:
static GraphicsContext &get_mutable_instance()
{
static GraphicsContext instance;
return instance;
}
bool setup(int width, int height, bool off_screen)
{
if (off_screen)
{
m_display = eglGetDisplay(EGL_DEFAULT_DISPLAY);
if (m_display == EGL_NO_DISPLAY)
{
std::cout << "Failed to get EGL display" << std::endl;
return false;
}
EGLint major, minor;
if (!eglInitialize(m_display, &major, &minor))
{
std::cout << "Failed to initialize EGL" << std::endl;
EGLint error = eglGetError();
std::cout << "EGL Error: " << std::hex << error << std::endl;
return false;
}
std::cout << "EGL version: " << major << "." << minor << std::endl;
// 绑定 OpenGL API
if (!eglBindAPI(EGL_OPENGL_API))
{
std::cout << "Failed to bind OpenGL API" << std::endl;
EGLint error = eglGetError();
std::cout << "EGL Error: " << std::hex << error << std::endl;
return false;
}
const EGLint config_attribs[] = {
EGL_SURFACE_TYPE, EGL_PBUFFER_BIT,
EGL_BLUE_SIZE, 8,
EGL_GREEN_SIZE, 8,
EGL_RED_SIZE, 8,
EGL_ALPHA_SIZE, 8,
EGL_DEPTH_SIZE, 24,
EGL_RENDERABLE_TYPE, EGL_OPENGL_BIT,
EGL_NONE};
EGLint num_configs;
if (!eglChooseConfig(m_display, config_attribs, &m_config, 1, &num_configs) || num_configs == 0)
{
std::cout << "Failed to choose EGL config" << std::endl;
EGLint error = eglGetError();
std::cout << "EGL Error: " << std::hex << error << std::endl;
return false;
}
const EGLint pbuffer_attribs[] = {
EGL_WIDTH, width,
EGL_HEIGHT, height,
EGL_NONE};
m_surface = eglCreatePbufferSurface(m_display, m_config, pbuffer_attribs);
if (m_surface == EGL_NO_SURFACE)
{
std::cout << "Failed to create EGL surface" << std::endl;
EGLint error = eglGetError();
std::cout << "EGL Error: " << std::hex << error << std::endl;
return false;
}
const EGLint context_attribs[] = {
EGL_NONE};
m_context = eglCreateContext(m_display, m_config, EGL_NO_CONTEXT, context_attribs);
if (m_context == EGL_NO_CONTEXT)
{
std::cout << "Failed to create EGL context" << std::endl;
EGLint error = eglGetError();
std::cout << "EGL Error: " << std::hex << error << std::endl;
return false;
}
if (!eglMakeCurrent(m_display, m_surface, m_surface, m_context))
{
std::cout << "Failed to make EGL context current" << std::endl;
EGLint error = eglGetError();
std::cout << "EGL Error: " << std::hex << error << std::endl;
return false;
}
std::cout << "EGL context successfully created." << std::endl;
return true;
}
else
{
// 非 off_screen 模式的处理
// ...
return true;
}
}
void swapBuffer()
{
eglSwapBuffers(m_display, m_surface);
}
private:
GraphicsContext() = default;
~GraphicsContext() = default;
EGLDisplay m_display = EGL_NO_DISPLAY;
EGLConfig m_config;
EGLSurface m_surface;
EGLContext m_context;
};
class Shader
{
public:
unsigned int ID;
Shader() {};
void initWithCode(const char *vsCode, const char *fsCode)
{
unsigned int vertex, fragment;
// vertex shader
vertex = glCreateShader(GL_VERTEX_SHADER);
glShaderSource(vertex, 1, (const char **)&vsCode, NULL);
glCompileShader(vertex);
// checkCompileErrors(vertex, "VERTEX");
// fragment Shader
fragment = glCreateShader(GL_FRAGMENT_SHADER);
glShaderSource(fragment, 1, (const char **)&fsCode, NULL);
glCompileShader(fragment);
// checkCompileErrors(fragment, "FRAGMENT");
// shader Program
ID = glCreateProgram();
glAttachShader(ID, vertex);
glAttachShader(ID, fragment);
glLinkProgram(ID);
// checkCompileErrors(ID, "PROGRAM");
// delete the shaders as they're linked into our program now and no longer necessery
glDeleteShader(vertex);
glDeleteShader(fragment);
}
void use()
{
glUseProgram(ID);
}
void setBool(const std::string &name, bool value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), (int)value);
}
void setInt(const std::string &name, int value) const
{
glUniform1i(glGetUniformLocation(ID, name.c_str()), value);
}
};
}
namespace
{
const char *triangle_v_shader = R"(
#version 300 es
precision mediump float;
layout (location = 0) in vec3 aPos;
void main()
{
gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
}
)";
const char *triangle_f_shader = R"(
#version 300 es
precision mediump float;
out vec4 FragColor;
void main()
{
FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
}
)";
bool IsFileExist(const std::string &file_path)
{
std::ifstream file(file_path);
return file.good();
}
}
int DrawTriangle()
{
bool off_screen = true;
auto &graphicsContext = GraphicsContext::get_mutable_instance();
if (!graphicsContext.setup(1920, 1080, off_screen))
{
std::cout << "Failed to setup GraphicsContext! \n";
return -1;
}
// create a shader
auto shader = std::make_shared<Shader>();
shader->initWithCode(triangle_v_shader, triangle_f_shader);
shader->use();
// create a triangle
float vertices[] = {
-0.5f, -0.5f, 0.0f, // left
0.5f, -0.5f, 0.0f, // right
0.0f, 0.5f, 0.0f // top
};
unsigned int VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// bind the Vertex Array Object first, then bind and set vertex buffer(s), and then configure vertex attributes(s).
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// position attribute
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void *)0);
glEnableVertexAttribArray(0);
// render loop
cv::Mat image(1080, 1920, CV_8UC4);
for (int frame = 0; frame < 2; frame++)
{
// input
image.setTo(cv::Scalar(0, 0, 0, 255));
// render
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// draw our first triangle
shader->use();
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
// swap buffers
graphicsContext.swapBuffer();
GLint viewPort[4] = {0};
glGetIntegerv(GL_VIEWPORT, viewPort);
glReadPixels(viewPort[0], viewPort[1], viewPort[2], viewPort[3], GL_RGBA, GL_UNSIGNED_BYTE, image.data);
std::string filename = "triangle_" + std::to_string(frame) + ".png";
std::cout << "Saving image to " << filename << std::endl;
cv::imwrite(filename, image);
cv::imshow("triangle", image);
cv::waitKey(0);
std::cout << "Image saved." << std::endl;
}
return 0;
}
void TestAntialialiasing()
{
auto &graphicsContext = GraphicsContext::get_mutable_instance();
if (!graphicsContext.setup(1920, 1080, true))
{
std::cout << "Failed to setup GraphicsContext! \n";
return;
}
auto shader = std::make_shared<Shader>();
shader->initWithCode(triangle_v_shader, triangle_f_shader);
shader->use();
float vertices[] = {
-0.5f, -0.5f, 0.0f, // left
0.5f, -0.5f, 0.0f, // right
0.0f, 0.5f, 0.0f // top
};
unsigned int VBO, VAO;
glGenVertexArrays(1, &VAO);
glGenBuffers(1, &VBO);
// bind the Vertex Array Object first, then bind and set vertex buffer(s), and then configure vertex attributes(s).
glBindVertexArray(VAO);
glBindBuffer(GL_ARRAY_BUFFER, VBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);
// position attribute
glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void *)0);
glEnableVertexAttribArray(0);
// // 屏幕四边形的顶点数据
float quadVertices[] = {
// 位置 // 纹理坐标
-1.0f, 1.0f, 0.0f, 1.0f,
-1.0f, -1.0f, 0.0f, 0.0f,
1.0f, -1.0f, 1.0f, 0.0f,
-1.0f, 1.0f, 0.0f, 1.0f,
1.0f, -1.0f, 1.0f, 0.0f,
1.0f, 1.0f, 1.0f, 1.0f};
// 创建 VAO 和 VBO
unsigned int quadVAO, quadVBO;
glGenVertexArrays(1, &quadVAO);
glGenBuffers(1, &quadVBO);
glBindVertexArray(quadVAO);
glBindBuffer(GL_ARRAY_BUFFER, quadVBO);
glBufferData(GL_ARRAY_BUFFER, sizeof(quadVertices), quadVertices, GL_STATIC_DRAW);
// 设置顶点属性
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void *)0);
glEnableVertexAttribArray(1);
glVertexAttribPointer(1, 2, GL_FLOAT, GL_FALSE, 4 * sizeof(float), (void *)(2 * sizeof(float)));
glBindVertexArray(0);
auto texture_shader = std::make_shared<Shader>();
texture_shader->initWithCode(vertex_shader_source, fragment_shader_source);
texture_shader->use();
// create a multisampled framebuffer
GLuint ms_fbo, ms_rbo, ms_texture;
glGenFramebuffers(1, &ms_fbo);
glBindFramebuffer(GL_FRAMEBUFFER, ms_fbo);
// create a multisampled color attachment texture
glGenRenderbuffers(1, &ms_rbo);
glBindRenderbuffer(GL_RENDERBUFFER, ms_rbo);
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_RGBA, 1920, 1080);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, ms_rbo);
// create a multisampled depth attachment texture
GLuint ms_depth_stencil_rbo;
glGenRenderbuffers(1, &ms_depth_stencil_rbo);
glBindRenderbuffer(GL_RENDERBUFFER, ms_depth_stencil_rbo);
glRenderbufferStorageMultisample(GL_RENDERBUFFER, 4, GL_DEPTH24_STENCIL8, 1920, 1080);
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_RENDERBUFFER, ms_depth_stencil_rbo);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
{
std::cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << std::endl;
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
// create a normal framebuffer
GLuint fbo, rbo, texture;
glGenFramebuffers(1, &fbo);
glBindFramebuffer(GL_FRAMEBUFFER, fbo);
// create a color attachment texture
glGenTextures(1, &texture);
glBindTexture(GL_TEXTURE_2D, texture);
glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1920, 1080, 0, GL_RGBA, GL_UNSIGNED_BYTE, NULL);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glBindTexture(GL_TEXTURE_2D, 0);
glFramebufferTexture2D(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, texture, 0);
if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE)
{
std::cout << "ERROR::FRAMEBUFFER:: Framebuffer is not complete!" << std::endl;
}
glBindFramebuffer(GL_FRAMEBUFFER, 0);
cv::Mat image(1080, 1920, CV_8UC4);
for (int frame = 0; frame < 2; frame++)
{
// render to ms_fbo
glBindFramebuffer(GL_FRAMEBUFFER, ms_fbo);
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
// draw our first triangle
shader->use();
glBindVertexArray(VAO);
glDrawArrays(GL_TRIANGLES, 0, 3);
// resolve multisampled buffer to normal buffer
glBindFramebuffer(GL_READ_FRAMEBUFFER, ms_fbo);
glBindFramebuffer(GL_DRAW_FRAMEBUFFER, fbo);
glBlitFramebuffer(0, 0, 1920, 1080, 0, 0, 1920, 1080, GL_COLOR_BUFFER_BIT, GL_NEAREST);
// draw to screen
glBindFramebuffer(GL_FRAMEBUFFER, 0);
glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
glClear(GL_COLOR_BUFFER_BIT);
texture_shader->use();
glBindVertexArray(quadVAO);
glActiveTexture(GL_TEXTURE0);
glBindTexture(GL_TEXTURE_2D, texture);
texture_shader->setInt("s_texture", 0);
glDrawArrays(GL_TRIANGLES, 0, 6);
graphicsContext.swapBuffer();
GLint viewPort[4] = {0};
glGetIntegerv(GL_VIEWPORT, viewPort);
glReadPixels(viewPort[0], viewPort[1], viewPort[2], viewPort[3], GL_RGBA, GL_UNSIGNED_BYTE, image.data);
std::string filename = "antialiasing_" + std::to_string(frame) + ".png";
std::cout << "Saving image to " << filename << std::endl;
cv::imwrite(filename, image);
cv::imshow("antialiasing", image);
if (cv::waitKey(0) == 27)
{
break;
}
}
}
#endif
int main()
{
#if !(defined(__arm__) || defined(__aarch64__))
DrawTriangle();
// TestFBO();
TestAntialialiasing();
std::cout << "Done! \n";
#endif
return 0;
}
在可执行程序旁边保存图片,整体看如下:
放大对比如下,左侧为多重采样的效果:
参考连接
- https://learnopengl-cn.github.io/04%20Advanced%20OpenGL/11%20Anti%20Aliasing/