• 1.opengl绘制三角形


     

    • 顶点数组对象:Vertex Array Object,VAO,用于存储顶点状态配置信息,每当界面刷新时,则通过VAO进行绘制.
    • 顶点缓冲对象:Vertex Buffer Object,VBO,通过VBO将大量顶点存储在GPU内存(通常被称为显存)中
     
    1.渲染步骤
    下面,你会看到一个图形渲染管线的每个阶段的抽象展示。要注意蓝色部分代表的是我们可以注入自定义的着色器的部分。
    • 注意:片段着色器也称为片元着色器
     
     
    顶点着色器(Vertex Shader)
    顶点着色器主要的目的是把3D坐标转为另一种3D坐标(后面会解释),同时顶点着色器允许我们对顶点属性进行一些基本处理。
     
    图元装配(Primitive Assembly)
    将顶点着色器输出的所有顶点作为输入(如果是GL_POINTS,那么就是一个顶点),并所有的点装配成指定图元的形状;本节例子中是一个三角形。
     
    几何着色器和光栅化阶段
    几何着色器的输出会被传入光栅化阶段(Rasterization Stage),这里它会把图元映射为最终屏幕上相应的像素,生成供片段着色器(Fragment Shader)使用的片段(Fragment)。在片段着色器运行之前会执行裁切(Clipping)。裁切会丢弃超出你的视图以外的所有像素,用来提升执行效率。
     
    片元着色器
    主要目的是计算一个像素的最终颜色,这也是所有OpenGL高级效果产生的地方。通常,片段着色器包含3D场景的数据(比如光照、阴影、光的颜色等等),这些数据可以被用来计算最终像素的颜色。
     
    测试和混合(Blending)阶段
    这个阶段检测片段的对应的深度(和模板(Stencil))值(后面会讲),用它们来判断这个像素是其它物体的前面还是后面,决定是否应该丢弃。这个阶段也会检查alpha值(alpha值定义了一个物体的透明度)并对物体进行混合(Blend)。所以,即使在片段着色器中计算出来了一个像素输出的颜色,在渲染多个三角形的时候最后的像素颜色也可能完全不同。
    在现代OpenGL中,我们必须定义至少一个顶点着色器和一个片段着色器(因为GPU中没有默认的顶点/片段着色器)。出于这个原因,刚开始学习现代OpenGL的时候可能会非常困难,因为在你能够渲染自己的第一个三角形之前已经需要了解一大堆知识了。在本节结束你最终渲染出你的三角形的时候,你也会了解到非常多的图形编程知识。
    而几何着色器是可选的,通常使用它默认的着色器就行了。
     
    2.通过代码实现每步骤
    2.1 顶点数据(Vertex Data)
    我们希望渲染一个三角形,所以创建三个顶点,我们将它顶点的z坐标设置为0.0。从而使它看上去像是2D的。
    float vertices[] = {
    -0.5f, -0.5f, 0.0f,
    0.5f, -0.5f, 0.0f,
    0.0f, 0.5f, 0.0f
    };
    然后我们通过glViewport函数进行视口变换(Viewport Transform),变换后的坐标x、y和z值在-1.0到1.0的一小段空间。任何落在范围外的坐标都会被丢弃/裁剪,不会显示在你的屏幕上。
    glViewport函数声明如下所示:
    glViewport(GLint x, GLint y, GLsizei width, GLsizei height);
    //x y:定义视口的左下角开始位置
    //width,height :定义这个视口矩形的宽度和高度
    如果设置全屏,一般都是 glViewport(0, 0, width(), height()),比如设置为glViewport(100, 50, 100,100)时,那么对应300x200窗口,是这样的:
    2.2 通过VBO将顶点存储到GPU内存中
    接下来我们还要通过顶点缓冲对象(Vertex Buffer Objects, VBO)管理这个内存,通过它将大量顶点存储在GPU内存(通常被称为显存)中。使用这些缓冲对象的好处是我们可以一次性的发送一大批数据到显卡上,而不是每个顶点发送一次。从CPU把数据发送到显卡相对较慢,所以只要可能我们都要尝试尽量一次性发送尽可能多的数据。当数据发送至显卡的内存中后,顶点着色器几乎能立即访问顶点,这是个非常快的过程。
    顶点缓冲对象是我们在OpenGL教程中第一个出现的OpenGL对象。就像OpenGL中的其它对象一样,这个缓冲有一个独一无二的ID,所以我们可以使用glGenBuffers函数和一个缓冲ID生成一个VBO对象:
    unsigned int VBO;
    glGenBuffers(1, &VBO);
    OpenGL有很多缓冲对象类型,顶点缓冲对象的缓冲类型是GL_ARRAY_BUFFER。OpenGL允许我们同时绑定多个缓冲,只要它们是不同的缓冲类型。我们可以使用glBindBuffer函数把新创建的缓冲绑定到GL_ARRAY_BUFFER目标上:
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    从这一刻起,我们使用的任何(在GL_ARRAY_BUFFER目标上的)缓冲调用都会用来配置当前绑定的缓冲(VBO)。然后我们可以调用glBufferData函数,它会把之前定义的顶点数据复制到缓冲的内存中:
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW); //把用户定义的数据复制到当前绑定缓冲对象上
    //参数1:目标缓冲的类型
    //参数2:传输数据的大小(以字节为单位)
    //参数3:数据指针
    //参数4:指定我们希望显卡如何管理给定的数据
    它有三种形式:
    • GL_STATIC_DRAW :数据不会或几乎不会改变(一次修改,多次使用)
    • GL_DYNAMIC_DRAW:数据会频繁修改(多次修改,多次使用)
    • GL_STREAM_DRAW :数据每次绘制时都会改变(每帧都不同,一次修改,一次使用)
     
    现在我们已经把顶点数据储存在显卡的内存中,用VBO这个顶点缓冲对象管理。下面我们会创建一个顶点和片段着色器来真正处理这些数据。
     
    2.3 顶点着色器源码
    做的第一件事是用着色器语言GLSL(OpenGL Shading Language)编写顶点着色器源码,下面你会看到一个非常基础的GLSL顶点着色器的源代码:
    const char *vertexShaderSource = "#version 330 core
    "
    "layout (location = 0) in vec3 aPos;
    "
    "void main()
    "
    "{
    "
    " gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
    "
    "}";
    • #version 330 core : 定义版本号,需要OpenGL 3.3或者更高版本
    • layout (location = 0) in vec3 aPos : 使用in关键字来声明顶点属性输入,这里创建一个输入变量aPos(3分量),通过layout (location = 0)设定了输入变量的顶点属性的位置值(Location)为0,后面将会通过glVertexAttribPointer()函数来设置它.
    • gl_Position : 设置顶点着色器的输出,这里gl_Position之所以为vec4类型,是因为3d图形演算要用到 4x4的矩阵(4行4列),而矩阵乘法要求n行m列 和 m行p列才能相乘,所以是vec4而不是vec3,由于position 是位置所以应该是 (x,y,z,1.0f),如果是方向向量,则就是 (x,y,z,0.0f).
    在真实的程序里输入数据通常都不是标准化设备坐标,所以我们首先必须先把它们转换至OpenGL的可视区域内。
     
    2.4 编译顶点着色器
    我们已经写了一个顶点着色器源码,但为了能够让OpenGL使用它,我们必须在运行时动态编译它的源码。
    我们首先要做的是创建一个顶点着色器对象,注意还是用ID来引用的。所以我们储存这个顶点着色器为unsigned int,然后用glCreateShader创建这个着色器:
    unsigned int vertexShader;
    vertexShader = glCreateShader(GL_VERTEX_SHADER);
    //创建一个着色器,参数GL_VERTEX_SHADER 或者GL_FRAGMENT_SHADER,由于我们创建的是顶点shader,所以填入GL_VERTEX_SHADER ,否则就是片元shader

    下一步我们把这个着色器源码附加到着色器对象上,然后编译它:

    glShaderSource(vertexShader, 1, &vertexShaderSource, NULL); //设置顶点源码
    glCompileShader(vertexShader);//编译源码
     
    int success;
    char infoLog[512];
    glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);//检测着色器编译是否成功
     
    if (!success)
    {
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);//返回着色器对象的信息日志
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED
    " << infoLog << std::endl;
    }
    glGetShaderiv函数不仅仅用于检测着色器编译成功,还可以判断类型,状态,源码等,函数定义如下所示:
    void glGetShaderiv(GLuint shader,GLenum pname,GLint *params);
    //shader:要查询的着色器对象
    //pname : 查询类型,可以设置的有GL_SHADER_TYPE、GL_DELETE_STATUS、GL_COMPILE_STATUS、GL_INFO_LOG_LENGTH.
    //GL_SHADER_TYPE:    用来判断并返回着色器类型,若是顶点着色器则success=GL_VERTEX_SHADER,若是片元着色器success=GL_FRAGMENT_SHADER
    //GL_DELETE_STATUS:   判断着色器是否被删除,success=GL_TRUE,否则success=GL_FALSE,
    //GL_COMPILE_STATUS:  用于检测编译是否成功,success=GL_TRUE,否则success=GL_FALSE,
    //GL_INFO_LOG_LENGTH: 获取着色器的信息日志的长度(information log length), 如果着色器没有信息日志,则success=0。
    //GL_SHADER_SOURCE_LENGTH:  获取着色器源码长度,不存在则success=0;
    //params:查询的内容
     
    2.5 片元着色器(也称为片段着色器)
    我们渲染三角形最后一步就是片元着色器,用来计算出每个像素的最终颜色。这里我们设置为输出橘黄色。
    在OpenGL或GLSL中,颜色每个分量的强度设置在0.0到1.0之间。比如说我们设置红为1.0f,绿为1.0f,我们会得到两个颜色的混合色,即黄色。
    所以片元着色器源码如下所示:
    const char *fragmentShaderSource = "#version 330 core
    "
    "out vec4 FragColor;
    "
    "void main()
    "
    "{
    "
    " FragColor = vec4(1.0f, 1.0f, 0.0f, 1.0f);
    "
    "}
    ";
    • FragColor : 定义类型为out vec4 类型,表明是个要输出的变量,该变量值为 vec4(1.0f, 1.0f, 0.0f, 1.0f),表示的是RGBA为(1,1,0,1),所以为黄色,而alpha值为1.0,表示完全不透明
     
    2.6 编译顶点着色器
    编译片段着色器的过程与顶点着色器类似,代码如下所示:
    unsigned int fragmentShader;
    fragmentShader = glCreateShader(GL_FRAGMENT_SHADER); //创建一个片元着色器
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);//设置片元源码
    glCompileShader(fragmentShader); //编译

    2.7 着色器Program对象

    两个着色器现在都编译了,接下来就是把两个着色器对象链接到一个用来渲染(调用顶点shader和片元shader数据)的着色器Program对象中。

    创建一个Program对象,并链接shader
    unsigned int shaderProgram;
    shaderProgram = glCreateProgram();
    glAttachShader(shaderProgram, vertexShader); //将附加vertexShader到的shaderProgram对象中
    glAttachShader(shaderProgram, fragmentShader);//将附加fragmentShader到的shaderProgram对象中
    glLinkProgram(shaderProgram); //将附加的shader链接到program对象中
     
    glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success); ////检测链接是否成功
    if (!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED
    " << infoLog << std::endl;
    }
     
    glDeleteShader(vertexShader); //链接后不再需要它们,需要删除shader
    glDeleteShader(fragmentShader);
    glGetProgramiv函数不仅仅用于检测链接成功,还可以获取着色器对象数量、激活的属性变量数等,比如:
    glGetProgramiv(shaderProgram, GL_ATTACHED_SHADERS, &cnt); //获取着色器对象的数量
    现在,我们已经把输入顶点数据发送给了GPU,但是OpenGL还不知道它该如何解释内存中的顶点数据(组件数量,数据类型,顶点个数),比如xyz坐标数据类型是GL_BYTE型,还是GL_SHORT型,还是GL_FLOAT型等
    所以我们需要通过glVertexAttribPointer()设置顶点属性,然后使能属性,并激活shaderProgram
     
    2.8 链接顶点属性
    我们的顶点缓冲数据会被解析为下面这样子:
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);//设置顶点属性
    glEnableVertexAttribArray(0);//使能顶点属性(默认是禁止的)
    glUseProgram(shaderProgram); //激活Program对象
    someOpenGLFunctionThatDrawsOurTriangle();// 绘制物体
    其中glVertexAttribPointer()函数声明如下所示:
    void glVertexAttribPointer(GLuint index , GLint size, GLenum type, GLboolean normalized, GLsizei stride, const void *ptr);
    //指定渲染时索引值为 index 的顶点属性数组的数据格式和位置。
    //index:指定要修改的顶点位置的索引值,之前使用layout(location = 0)设置了顶点位置为0,
    //size:指定每个顶点属性的组件数量。必须为1、2、3、4之一。(如我们这里顶点是由3个(x,y,z)组成,而颜色是4个(r,g,b,a))
    //type:指定数组中每个组件的数据类型。可用的符号常量有GL_BYTE, GL_UNSIGNED_BYTE, GL_SHORT,GL_UNSIGNED_SHORT, GL_FIXED, 和 GL_FLOAT
    //normalized:是否希望数据被标准化。设置为GL_TRUE,所有数据都会被映射到0(对于有符号型signed数据是-1)到1之间。我们把它设置为GL_FALSEM,不需要。
    //stride:步长,指定顶点之间的偏移量。我们这里是每3个float为一个顶点,所以偏移量为3 * sizeof(float)
    //ptr:可以指向需要绑定的VBO,如果已经绑定VBO,并且位置数据在缓冲中起始位置为0,那么此项为0,否则填入开头的偏移量

    当我们绘制好物体后,每当最大化,尺寸变化界面后,openGL就会进入刷新状态,所以我们需要把所有这些状态配置储存在一个顶点数组对象(Vertex Array Object, VAO)中,每次刷新时,就可以通过VAO来恢复状态.

    2.9 顶点数组对象VAO实现
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f); //设置清除颜色(背景色)为rgba(0.2f, 0.3f, 0.3f, 1.0f)
    
    
    //  初始化代码,初始化顶点shader、片元shader、program、vbo
    // ... ..
    // ... ...
    
    //1.初始化vao
    unsigned int VAO;
    glGenVertexArrays(1, &VAO); // 注册VAO
    glBindVertexArray(VAO);     // 绑定VAO
    
    //2. 把顶点数组复制到缓冲中供OpenGL使用
    glBindBuffer(GL_ARRAY_BUFFER, VBO);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);//把用户定义的顶点数据复制到vao上
    //3. 设置顶点属性指针 glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(GLfloat), (GLvoid*)0); glEnableVertexAttribArray(0);//使能顶点属性(默认是禁止的) //4. 解绑VAO glBindVertexArray(0);
    然后每次绘制物体时,只需要:
    glClear(GL_COLOR_BUFFER_BIT); //开始清除,设置背景色
    glUseProgram(shaderProgram); ////激活Program对象
    glBindVertexArray(VAO);
    glDrawArrays(GL_TRIANGLES, 0, 3); //绘制三角形
    someOpenGLFunctionThatDrawsOurTriangle();// 绘制物体
    glBindVertexArray(0); //绘制完成,便解绑,用来绑定下一个要绘制的物体
    这里,我们一直再调用glBindVertexArray()绑定和解绑,是为了方便绘制有多个VAO的时候,避免出错(如果只绘制一个VAO,也可以无需多次解绑).
     
    其中glDrawArrays函数声明如下所示:
    glDrawArrays(GLenum mode, GLint first, GLsizei count);
    //函数根据顶点数组中的坐标数据和指定的模式,进行绘制。
    //mode,绘制方式,如下图所示,提供以下参数:
    //GL_POINTS(画点)、GL_LINES(每两个顶点为一条直线)、GL_LINE_LOOP(是个环状)、
    //GL_LINE_STRIP(第一个顶点和最后一个顶点不相连)、GL_TRIANGLES(每三个顶点组成一个三角形)、
    //GL_TRIANGLE_STRIP(共用多个顶点的一个三角形)、GL_TRIANGLE_FAN(共用一个原点为中心的一个三角形)。
    
    //first,从数组缓存中的哪一位开始绘制,一般为0。
    //count,数组中顶点的数量。

    如下图所示:

     
    2.10 最终代码如下所示:
    //hello_triangle.cpp
    #include <glad/glad.h>
    #include <GLFW/glfw3.h>
     
    #include <iostream>
     
    void framebuffer_size_callback(GLFWwindow* window, int width, int height);
    void processInput(GLFWwindow *window);
     
    // settings
    const unsigned int SCR_WIDTH = 800;
    const unsigned int SCR_HEIGHT = 600;
     
    const char *vertexShaderSource = "#version 330 core
    "
    "layout (location = 0) in vec3 aPos;
    "
    "void main()
    "
    "{
    "
    " gl_Position = vec4(aPos.x, aPos.y, aPos.z, 1.0);
    "
    "}";
    const char *fragmentShaderSource = "#version 330 core
    "
    "out vec4 FragColor;
    "
    "void main()
    "
    "{
    "
    " FragColor = vec4(1.0f, 0.5f, 0.2f, 1.0f);
    "
    "}
    ";
     
    int main()
    {
    // glfw: initialize and configure
    // ------------------------------
    glfwInit();
    glfwWindowHint(GLFW_CONTEXT_VERSION_MAJOR, 3);
    glfwWindowHint(GLFW_CONTEXT_VERSION_MINOR, 3);
    glfwWindowHint(GLFW_OPENGL_PROFILE, GLFW_OPENGL_CORE_PROFILE);
     
    #ifdef __APPLE__
    glfwWindowHint(GLFW_OPENGL_FORWARD_COMPAT, GL_TRUE); // uncomment this statement to fix compilation on OS X
    #endif
     
    // glfw window creation
    // --------------------
    GLFWwindow* window = glfwCreateWindow(SCR_WIDTH, SCR_HEIGHT, "LearnOpenGL", NULL, NULL);
    if (window == NULL)
    {
    std::cout << "Failed to create GLFW window" << std::endl;
    glfwTerminate();
    return -1;
    }
    glfwMakeContextCurrent(window);
    glfwSetFramebufferSizeCallback(window, framebuffer_size_callback);
     
    // glad: load all OpenGL function pointers
    // ---------------------------------------
    if (!gladLoadGLLoader((GLADloadproc)glfwGetProcAddress))
    {
    std::cout << "Failed to initialize GLAD" << std::endl;
    return -1;
    }
     
     
    // build and compile our shader program
    // ------------------------------------
    // vertex shader
    int vertexShader = glCreateShader(GL_VERTEX_SHADER);
    glShaderSource(vertexShader, 1, &vertexShaderSource, NULL);
    glCompileShader(vertexShader);
    // check for shader compile errors
    int success;
    char infoLog[512];
    glGetShaderiv(vertexShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
    glGetShaderInfoLog(vertexShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::VERTEX::COMPILATION_FAILED
    " << infoLog << std::endl;
    }
    // fragment shader
    int fragmentShader = glCreateShader(GL_FRAGMENT_SHADER);
    glShaderSource(fragmentShader, 1, &fragmentShaderSource, NULL);
    glCompileShader(fragmentShader);
    // check for shader compile errors
    glGetShaderiv(fragmentShader, GL_COMPILE_STATUS, &success);
    if (!success)
    {
    glGetShaderInfoLog(fragmentShader, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::FRAGMENT::COMPILATION_FAILED
    " << infoLog << std::endl;
    }
    // link shaders
    int shaderProgram = glCreateProgram();
    glAttachShader(shaderProgram, vertexShader);
    glAttachShader(shaderProgram, fragmentShader);
    glLinkProgram(shaderProgram);
    // check for linking errors
    glGetProgramiv(shaderProgram, GL_LINK_STATUS, &success);
    if (!success) {
    glGetProgramInfoLog(shaderProgram, 512, NULL, infoLog);
    std::cout << "ERROR::SHADER::PROGRAM::LINKING_FAILED
    " << infoLog << std::endl;
    }
    glDeleteShader(vertexShader);
    glDeleteShader(fragmentShader);
     
    // set up vertex data (and buffer(s)) and configure vertex attributes
    // ------------------------------------------------------------------
    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);
     
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), (void*)0);
    glEnableVertexAttribArray(0);
     
    // note that this is allowed, the call to glVertexAttribPointer registered VBO as the vertex attribute's bound vertex buffer object so afterwards we can safely unbind
    glBindBuffer(GL_ARRAY_BUFFER, 0);
     
    // You can unbind the VAO afterwards so other VAO calls won't accidentally modify this VAO, but this rarely happens. Modifying other
    // VAOs requires a call to glBindVertexArray anyways so we generally don't unbind VAOs (nor VBOs) when it's not directly necessary.
    glBindVertexArray(0);
     
     
    // uncomment this call to draw in wireframe polygons.
    //glPolygonMode(GL_FRONT_AND_BACK, GL_LINE);
     
    // render loop
    // -----------
    while (!glfwWindowShouldClose(window))
    {
    // input
    // -----
    processInput(window);
     
    // render
    // ------
    glClearColor(0.2f, 0.3f, 0.3f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
     
    // draw our first triangle
    glUseProgram(shaderProgram);
    glBindVertexArray(VAO); // seeing as we only have a single VAO there's no need to bind it every time, but we'll do so to keep things a bit more organized
    glDrawArrays(GL_TRIANGLES, 0, 3);
    // glBindVertexArray(0); // no need to unbind it every time
     
    // glfw: swap buffers and poll IO events (keys pressed/released, mouse moved etc.)
    // -------------------------------------------------------------------------------
    glfwSwapBuffers(window);
    glfwPollEvents();
    }
     
    // optional: de-allocate all resources once they've outlived their purpose:
    // ------------------------------------------------------------------------
    glDeleteVertexArrays(1, &VAO);
    glDeleteBuffers(1, &VBO);
     
    // glfw: terminate, clearing all previously allocated GLFW resources.
    // ------------------------------------------------------------------
    glfwTerminate();
    return 0;
    }
     
    // process all input: query GLFW whether relevant keys are pressed/released this frame and react accordingly
    // ---------------------------------------------------------------------------------------------------------
    void processInput(GLFWwindow *window)
    {
    if (glfwGetKey(window, GLFW_KEY_ESCAPE) == GLFW_PRESS)
    glfwSetWindowShouldClose(window, true);
    }
     
    // glfw: whenever the window size changed (by OS or user resize) this callback function executes
    // ---------------------------------------------------------------------------------------------
    void framebuffer_size_callback(GLFWwindow* window, int width, int height)
    {
    // make sure the viewport matches the new window dimensions; note that width and
    // height will be significantly larger than specified on retina displays.
    glViewport(0, 0, width, height);
    }

     未完待续 ,下章学习:

    2.通过QOpenGLWidget绘制三角形

  • 相关阅读:
    SDN3
    SDN2
    SDN1
    软工实践5
    2019 SDN上机第二次作业
    2019 SDN上机第一次作业
    软件工程实践2019第五次作业
    软件工程实践2019第四次作业
    软件工程实践2019第三次作业
    软件工程实践2019第二次作业
  • 原文地址:https://www.cnblogs.com/lifexy/p/13723686.html
Copyright © 2020-2023  润新知