使用Qt和OpenGL实现一个旋转的各面颜色不一致的立方体及知识点分析

今天来实现一个会旋转的立方体,这是OpenGL的第一部分的主要知识,第二部分光照及第三部分纹理等等后面再说。

效果图如下:

问题:正面及背面缺了一小块三角形

原因及解决方案:由于启用了深度测试,背面的三角形绘制时未遵循逆时针的绘制顺序。导致法线方向搞反了。

1.话不多说,先看整体项目结构:

首先是cmakelists.txt,它和Qt的pro文件的作用是一样的,都是用于组织依赖库及项目头文件及源文件,有关CMAKE的知识大家可以找个比如opencv的库自己编一下然后在项目中用试试。

1.1 camkelist.txt内容解析

cpp 复制代码
cmake_minimum_required(VERSION 3.16)

project(cube VERSION 0.1 LANGUAGES CXX)

set(CMAKE_AUTOUIC ON)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)

find_package(QT NAMES Qt6 Qt5 REQUIRED COMPONENTS Widgets)
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets Opengl OpenGLWidgets Gui)

set(PROJECT_SOURCES
        main.cpp
        mainwindow.cpp
        mainwindow.h
)

if(${QT_VERSION_MAJOR} GREATER_EQUAL 6)
    qt_add_executable(cube
        MANUAL_FINALIZATION
        ${PROJECT_SOURCES}
        openglwidget.h openglwidget.cpp
        resources.qrc
    )
# Define target properties for Android with Qt 6 as:
#    set_property(TARGET cube APPEND PROPERTY QT_ANDROID_PACKAGE_SOURCE_DIR
#                 ${CMAKE_CURRENT_SOURCE_DIR}/android)
# For more information, see https://doc.qt.io/qt-6/qt-add-executable.html#target-creation
else()
    if(ANDROID)
        add_library(cube SHARED
            ${PROJECT_SOURCES}
        )
# Define properties for Android with Qt 5 after find_package() calls as:
#    set(ANDROID_PACKAGE_SOURCE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/android")
    else()
        add_executable(cube
            ${PROJECT_SOURCES}
        )
    endif()
endif()

target_link_libraries(cube PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt6::OpenGL Qt6::OpenGLWidgets Qt6::Gui)

# Qt for iOS sets MACOSX_BUNDLE_GUI_IDENTIFIER automatically since Qt 6.1.
# If you are developing for iOS or macOS you should consider setting an
# explicit, fixed bundle identifier manually though.
if(${QT_VERSION} VERSION_LESS 6.1.0)
  set(BUNDLE_ID_OPTION MACOSX_BUNDLE_GUI_IDENTIFIER com.example.cube)
endif()
set_target_properties(cube PROPERTIES
    ${BUNDLE_ID_OPTION}
    MACOSX_BUNDLE_BUNDLE_VERSION ${PROJECT_VERSION}
    MACOSX_BUNDLE_SHORT_VERSION_STRING ${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}
    MACOSX_BUNDLE TRUE
    WIN32_EXECUTABLE TRUE
)

include(GNUInstallDirs)
install(TARGETS cube
    BUNDLE DESTINATION .
    LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}
    RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
)

if(QT_VERSION_MAJOR EQUAL 6)
    qt_finalize_executable(cube)
endif()

Qt可以默认生成cmakelist.txt,推荐你使用自带生成的文件,其实你需要改的地方总共有两行:

find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS Widgets Opengl OpenGLWidgets Gui)

这一行的作用是在Qt安装的库中包括系统变量中找到这个库添加上去,这保证了你添加头文件以及使用函数时不会报错,首先OpenGL,OpenGLWidgets,Gui这三个库是需要手动添加的。当然要确保你的Qt中安装了这些模块。

target_link_libraries(cube PRIVATE Qt${QT_VERSION_MAJOR}::Widgets Qt6::OpenGL Qt6::OpenGLWidgets Qt6::Gui)

上面这一行也是需要添加的,这一行的作用是编译时链接库,如果链接不到编译时会报错,请仔细检查你要添加的库的名称,因为不同版本的Qt也许库名称不太一样。

1.2 main函数

cpp 复制代码
#include "mainwindow.h"

#include <QApplication>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    MainWindow w;
    w.show();
    return a.exec();
}

main函数就是默认的函数,主要用于应用程序的生成,以及显示主窗口,并在最后启用事件循环,用于接收鼠标等事件。

1.3 MainWindow.h和MainWindow.cpp

cpp 复制代码
#ifndef MAINWINDOW_H
#define MAINWINDOW_H

#include <QMainWindow>
#include "openglwidget.h"
class MainWindow : public QMainWindow
{
    Q_OBJECT

public:
    MainWindow(QWidget *parent = nullptr);
    ~MainWindow();

private:
    openglwidget* m_glwidget;
};
#endif // MAINWINDOW_H
cpp 复制代码
#include "mainwindow.h"

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
{
    m_glwidget = new openglwidget(this);
    setCentralWidget(m_glwidget);
    resize(800, 600);

}

MainWindow::~MainWindow() {}

主窗口的作用就是将我们的opengl窗口添加进去。

2. openglwidget窗口设计

cpp 复制代码
#ifndef OPENGLWIDGET_H
#define OPENGLWIDGET_H

#include <QObject>
#include <QWidget>
#include <QOpenGLShaderProgram>
#include <QOpenGLWidget>
#include <QOpenGLBuffer>
#include <QOpenGLExtraFunctions>
#include <QOpenGLContext>
#include <QMatrix4x4>
#include <QTimer>
#include <QMouseEvent>

class openglwidget: public QOpenGLWidget, protected QOpenGLExtraFunctions
{
    Q_OBJECT
public:
    openglwidget(QWidget *parent);
    void initializeGL()override;

    void paintGL()override;

    void resizeGL(int w, int h)override;

    void mousePressEvent(QMouseEvent* ev)override;

    void mouseMoveEvent(QMouseEvent* ev)override;

    void mouseReleaseEvent(QMouseEvent* ev)override;

private:

    QOpenGLShaderProgram* m_program;
    GLuint m_vao;
    GLuint m_vbo;
    GLuint m_ebo;

    QMatrix4x4 m_model;
    QMatrix4x4 m_view;
    QMatrix4x4 m_projection;

    bool m_isDragging;
    QPoint m_lastPoint;

    float m_rotateX;
    float m_rotateY;


};

#endif // OPENGLWIDGET_H

如上所述,该类继承自两个类,QOpenGLWidget和QOpenGLExtraWidget, 分别用来显示窗口和使用OpenGL函数。

cpp 复制代码
#include "openglwidget.h"

openglwidget::openglwidget(QWidget* parent):QOpenGLWidget(parent)
{
    m_isDragging = false;
    m_program = new QOpenGLShaderProgram(this);
    m_rotateX = 0;
    m_rotateY = 0;
}

void openglwidget::initializeGL()
{
    initializeOpenGLFunctions();
    glClearColor(0.3f, 0.5f, 0.7f, 1.0f);
    glEnable(GL_DEPTH_TEST);
    glDisable(GL_CULL_FACE);

    //着色器
    m_program->addShaderFromSourceFile(QOpenGLShader::Vertex, ":/vertex.vert");
    m_program->addShaderFromSourceFile(QOpenGLShader::Fragment, ":/frag.frag");
    m_program->link();

    //顶点
    GLfloat vertices[] = {
        // 位置            //
        -0.5f,  0.5f,  0.5f, 0.2f, 0.4f, 0.7f,// 0
        0.5f,  0.5f,  0.5f,  0.2f, 0.4f, 0.7f,// 1
        0.5f, -0.5f,  0.5f,  0.2f, 0.4f, 0.7f,// 2
        -0.5f, -0.5f,  0.5f, 0.2f, 0.4f, 0.7f,// 3

        -0.5f,  0.5f, -0.5f, 0.5f, 0.3f, 0.7f,// 4
        0.5f,  0.5f, -0.5f,  0.5f, 0.3f, 0.7f,// 5
        0.5f, -0.5f, -0.5f,  0.5f, 0.3f, 0.7f,// 6
        -0.5f, -0.5f, -0.5f, 0.5f, 0.3f, 0.7f,// 7

        -0.5f,  0.5f,  0.5f, 0.6f, 0.8f, 0.2f,// 8 (重复 0)
        -0.5f,  0.5f, -0.5f, 0.6f, 0.8f, 0.2f,// 9 (重复 4)
        -0.5f, -0.5f, -0.5f, 0.6f, 0.8f, 0.2f,// 10 (重复 7)
        -0.5f, -0.5f,  0.5f, 0.6f, 0.8f, 0.2f,// 11 (重复 3)

        0.5f,  0.5f,  0.5f,  0.9f, 0.3f, 0.6f,// 12 (重复 1)
        0.5f,  0.5f, -0.5f,  0.9f, 0.3f, 0.6f,// 13 (重复 5)
        0.5f, -0.5f, -0.5f,  0.9f, 0.3f, 0.6f,// 14 (重复 6)
        0.5f, -0.5f,  0.5f,  0.9f, 0.3f, 0.6f,// 15 (重复 2)

        -0.5f,  0.5f,  0.5f, 0.4f, 0.5f, 0.6f,// 16 (重复 0)
        0.5f,  0.5f,  0.5f,  0.4f, 0.5f, 0.6f,// 17 (重复 1)
        0.5f,  0.5f, -0.5f,  0.4f, 0.5f, 0.6f,// 18 (重复 5)
        -0.5f,  0.5f, -0.5f, 0.4f, 0.5f, 0.6f,// 19 (重复 4)

        -0.5f, -0.5f,  0.5f, 0.6f, 0.3f, 0.5f,// 20 (重复 3)
        0.5f, -0.5f,  0.5f,  0.6f, 0.3f, 0.5f,// 21 (重复 2)
        0.5f, -0.5f, -0.5f,  0.6f, 0.3f, 0.5f,// 22 (重复 6)
        -0.5f, -0.5f, -0.5f, 0.6f, 0.3f, 0.5f,// 23 (重复 7)
    };

    GLuint indices[] = {
        // 前面 (Front Face)
        0, 1, 2, 2, 3, 0,

        // 后面 (Back Face)
        4, 5, 6, 6, 7, 4,

        // 左面 (Left Face)
        8, 9, 10, 10, 11, 8,

        // 右面 (Right Face)
        12, 13, 14, 14, 15, 12,

        // 上面 (Top Face)
        16, 17, 18, 18, 19, 16,

        // 下面 (Bottom Face)
        20, 21, 22, 22, 23, 20,
    };

    glGenVertexArrays(1, &m_vao);
    glBindVertexArray(m_vao);

    glGenBuffers(1, &m_vbo);
    glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    glGenBuffers(1, &m_ebo);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW );

    GLenum error = glGetError();
    if (error != GL_NO_ERROR) {
        qCritical() << "OpenGL Error after setting up VBO:" << error;
        return;
    }

    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), NULL);
    glEnableVertexAttribArray(0);

    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
    glEnableVertexAttribArray(1);

    //变换矩阵
    m_model.setToIdentity();
    m_view.setToIdentity();
    m_view.translate(0.0f, 0.0f, -3.0f);
    m_projection.setToIdentity();
    m_projection.perspective(45.0f, 8.0f/6.0f, 0.1f, 100.0f);

    glBindVertexArray(0);



}

void openglwidget::paintGL()
{
    glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);
    m_program->bind();
    glBindVertexArray(m_vao);
    m_program->setUniformValue("amodel", m_model);
    m_program->setUniformValue("aview", m_view);
    m_program->setUniformValue("aprojection", m_projection);

    glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, (void*)0);


}

void openglwidget::resizeGL(int w, int h)
{
    glViewport(0, 0, w, h);
    m_projection.setToIdentity();
    m_projection.perspective(45.0f,static_cast<float>(w)/h, 0.1f, 100.0f);
}

void openglwidget::mousePressEvent(QMouseEvent* ev)
{
    if(ev->button() == Qt::LeftButton)
    {
        m_lastPoint = ev->pos();
        m_isDragging = true;
    }
}

void openglwidget::mouseMoveEvent(QMouseEvent* ev)
{
    if ((ev->buttons() | Qt::LeftButton) && m_isDragging)
    {
        int dx = ev->pos().x() - m_lastPoint.x();
        int dy = ev->pos().y() - m_lastPoint.y();

        m_rotateX = 0.5 * dy;
        m_rotateY = 0.5 * dx;
        m_lastPoint = ev->pos();

        if (m_rotateX >89.0f)
            m_rotateX = 89.0f;
        if (m_rotateX < -89.0f)
            m_rotateX = -89.0f;

        if (m_rotateY >89.0f)
            m_rotateY = 89.0f;
        if (m_rotateY < -89.0f)
            m_rotateY = -89.0f;

        m_model.rotate(m_rotateX, 1, 0, 0);
        m_model.rotate(m_rotateY, 0, 1, 0);


        update();
    }
}

void openglwidget::mouseReleaseEvent(QMouseEvent* ev)
{
    if (ev->button() == Qt::LeftButton)
    {
        m_isDragging = false;
    }
}

上述就是完整的 opengl设计,下面来详细解释是怎么设计的

2.1 void initializeGL()override;

这个函数是Qt封装的函数,是必须要实现的函数。

cpp 复制代码
initializeOpenGLFunctions();

这一句的作用是初始化OpenGL的函数,你想用opengl的函数指针,你就得加这句话。

cpp 复制代码
    glClearColor(0.3f, 0.5f, 0.7f, 1.0f);
    glEnable(GL_DEPTH_TEST);
    glDisable(GL_CULL_FACE);

这三句进行一个设置,首先清除颜色相当于背景颜色,效果图的背景是蓝色的,就是在这里设置的,4个浮点数分别代表红绿蓝和透明度。

第二句启用了深度测试,深度测试(Depth Testing),也称为Z-buffering,是用于确定场景中的哪些部分应该被绘制、哪些部分应该被隐藏的过程。它通过比较每个像素的深度值来决定是否更新颜色缓冲区中的对应像素。深度测试有助于正确地渲染三维场景中的遮挡关系,确保离观察者较近的物体遮挡住远处的物体。

第三句不使用面剔除,它通过忽略那些背向观察者的多边形面来减少需要处理的几何体数量,从而加快渲染速度。这是因为,在一个封闭的三维模型中,通常只有一半的面(即朝向观察者的那一面)是可见的,而另一面则是不可见的。

cpp 复制代码
/着色器
    m_program->addShaderFromSourceFile(QOpenGLShader::Vertex, ":/vertex.vert");
    m_program->addShaderFromSourceFile(QOpenGLShader::Fragment, ":/frag.frag");
    m_program->link();

着色器程序的加载编译与链接,使用QOpenGLShaderProgram来简化这一过程。添加了顶点着色器和片段着色器。这两个着色器是运行在GPU上的程序。

cpp 复制代码
GLfloat vertices[] =

顶点坐标,坐标也有讲究的,如上我的顶点一共有24个,这是因为立方体有6个面,每个面有4个顶点,而每个顶点有6个float值,前三个是顶点坐标值,后三个是颜色的值。

坐标系大概是这样:

z轴正方向是垂直于屏幕的,y轴向上,x向右。

cpp 复制代码
GLuint indices[] =

接着就是索引坐标,这个数组代表了绘制的顺序,要记住,每个面都必须是逆时针排列的,就是你在每个面的外面去看这几个点是逆时针。而且由于我们绘制的是三角形,所以说每个正方形需要两个三角形,就是需要6个点代表两个三角形,每3个点需要逆时针排列,而且两个三角形拼成一个正方形。

cpp 复制代码
    glGenVertexArrays(1, &m_vao);//1代表生成1个
    glBindVertexArray(m_vao);

    glGenBuffers(1, &m_vbo);
    glBindBuffer(GL_ARRAY_BUFFER, m_vbo);
    glBufferData(GL_ARRAY_BUFFER, sizeof(vertices), vertices, GL_STATIC_DRAW);

    glGenBuffers(1, &m_ebo);
    glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, m_ebo);
    glBufferData(GL_ELEMENT_ARRAY_BUFFER, sizeof(indices), indices, GL_STATIC_DRAW );

这一段是vao,vbo,ebo的创建与上传数据到GPU上,vao的作用是如何解释顶点数据和颜色数据,而vbo负责将数据上传到GPU上,ebo负责将索引数据上传到GPU上。

cpp 复制代码
    glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), NULL);
    glEnableVertexAttribArray(0);

    glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));
    glEnableVertexAttribArray(1);

这几行的意义就是vao如何解释数据,参数的意义:

0代表顶点着色器中第一个位置,默认是顶点数据,

3代表由3个分量表示一个数据,即xyz, 数据类型是GL_FLOAT,

参数 GL_FALSE 是用于指定是否应该对传入的数据进行标准化(normalize)处理,

6 * sizeof(float)连续顶点属性之间的字节偏移量。如果为0,则假定属性是紧密排列的。

最后一个参数指向第一个组件的第一个顶点的指针。该数组与当前绑定的缓冲区对象关联,如果使用的是顶点缓冲对象(VBO),则此参数是一个偏移量。

cpp 复制代码
    //变换矩阵
    m_model.setToIdentity();
    m_view.setToIdentity();
    m_view.translate(0.0f, 0.0f, -3.0f);
    m_projection.setToIdentity();
    m_projection.perspective(45.0f, 8.0f/6.0f, 0.1f, 100.0f);

变换矩阵有模型矩阵,视图矩阵和投影矩阵,首先都初始化为单位矩阵,这三个矩阵都是4x4的,这是因为方便变换,如果对这方面不太熟的话可以看看learnopengl教程。

其中视图矩阵将世界坐标系转换成摄像机坐标系,除了使用平移的方法构建,也可以使用lookat函数构建。

最后一句是投影变换矩阵,perspective是透视投影,这种方式可以模拟人的眼睛,远的物体会显得小,第一个参数45.0f就是比较合理的值,第二个参数是宽高比,由于我的窗体初始化为800,600,第三个参数是近点,第四个参数是远点,一般是0.1f和100.0f。

这方面需要坐标系的相关知识,还需要矩阵的运算原理,正好可以复习一下。

2.2 void paintGL()override

这个函数同样是一个虚函数,需要自己来实现,主要是绘制的一些操作。每次更新帧的时候都需要调用这个函数来绘制。

cpp 复制代码
glClear(GL_DEPTH_BUFFER_BIT | GL_COLOR_BUFFER_BIT);

这一行的作用是清除深度缓冲区和颜色缓冲区,防止上一帧的绘制结果影响到当前绘制帧。

cpp 复制代码
    m_program->setUniformValue("amodel", m_model);
    m_program->setUniformValue("aview", m_view);
    m_program->setUniformValue("aprojection", m_projection);

这三句的作用是向着色器语言程序传递全局变量,第一个参数名是着色器程序中的变量名,而第二个参数是c++当前程序定义的变量名,通过这种方式将变换矩阵上传到GPU的着色器程序中,利用GPU去运算。

cpp 复制代码
glDrawElements(GL_TRIANGLES, 36, GL_UNSIGNED_INT, (void*)0);

这一句就是绘制命令,有多种绘制命令,这里使用的是绘制索引的函数,

第一个参数是绘制三角形,

第二个参数是绘制的顶点索引数量,

第三个参数是绘制数据的类型,索引数据常用。

第四个参数如果使用了vbo等上传到GPU上,填(void*)0就可以了。

3.着色器编程

之前的讲解中出现了着色器,这里说一下着色器干什么事情。这里将着色器分开一张,因为着色器可以干很多事情,属于GPU编程,是不是顿时感觉高大上起来了呀。

3.1 vertex顶点着色器

顶点着色器主要用来设置顶点位置,颜色,纹理,以及做矩阵变换。

cpp 复制代码
#version 330 core

顶点着色器的第一行是版本设置,必须设置。这里使用经典330,功能已经足够。使用核心模式。

cpp 复制代码
layout (location = 0)in vec3 aPos;
layout(location = 1) in vec3 aColor;

这两行就是设置vao的时候用到的,第一个布局是位置0,属于顶点位置,第二个是位置1,属于顶点颜色,就是在这个地方用到的:

glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), NULL);

glEnableVertexAttribArray(0);

glVertexAttribPointer(1, 3, GL_FLOAT, GL_FALSE, 6 * sizeof(float), (void*)(3 * sizeof(float)));

glEnableVertexAttribArray(1);

接下来是uniform全局变量:

cpp 复制代码
uniform mat4 amodel;
uniform mat4 aview;
uniform mat4 aprojection;

全局变量的作用就是在C++代码中可以往gpu上传变量值,这里我们定义了三个4x4矩阵,在之前我们已经上传过。

cpp 复制代码
out vec4 aOutColor;

这里的out变量顾名思义就是输出的值,学理工科的都知道IO这个词的意思,这里的out是指将这个变量传递给片段着色器。

cpp 复制代码
void main(void)
{
    gl_Position = aprojection* aview * amodel * vec4(aPos, 1.0);
    aOutColor =vec4(aColor, 1.0);
}

这个类似于c语言的规范,顶点位置的变换是这样的:

首先x,y,z三个分量再添加一个齐次坐标系的分量1.0,然后先使用模型矩阵将局部坐标系变换到世界坐标系,然后依次经过视图矩阵和投影矩阵进行坐标系转换,分别进入摄像机坐标系和裁剪坐标系。

颜色我们不进行变换,之间传给片段着色器。

3.2 fragment片段着色器

片段着色器是用来处理每个像素的颜色的地方,达到更精细的显示效果。

但是在这里我们并没有进行什么操作,这是因为颜色我们在c++程序中设置好了,每个面一个颜色。

这里就不展开讲了,后续做光照时再详细讲。

4. Qt设置立方体旋转

这部分比较简单,就是固定的写法,使用鼠标的点击移动释放事件三个函数完成,其中注意的是在鼠标移动时,要设置模型矩阵的旋转操作,然后进行update,这里的更新会调用paintGL函数,默认使用了双缓冲技术以及批量更新的技巧。

这里需要注意的是横着划动鼠标时立方体是绕y轴旋转的,所以要乘y分量。

看代码就能看懂了。

欢迎大家评论。

相关推荐
用户805533698033 天前
不止三件套:QObject 属性系统全关键字与运行时反射!
c++·qt
xcyxiner3 天前
DicomViewer (vcpkg Windows和ubuntu编译)7
qt
Quz8 天前
QML Hello World 入门示例
qt
xcyxiner11 天前
DicomViewer (dcmtk读取dcm文件)5
qt
xcyxiner11 天前
DicomViewer (后台线程处理文件)4
qt
xcyxiner12 天前
DicomViewer (添加模型类)3
qt
xcyxiner12 天前
DicomViewer (目录调整) 2
qt
xcyxiner13 天前
dcmtk vtk vtk-dicom(gdcm) 编译(debug) v2
qt
LDR00614 天前
Type-C 快充全面升级!LDR6601 赋能个人护理便携电机,重塑剃须刀 / 理发器新体验
c语言·开发语言
雪碧聊技术14 天前
Tree.js是什么?一文讲透
开发语言·javascript·ecmascript