Creating a Skybox Using C++, Qt and OpenGL

Skyboxes are commonly used in video games to create a realistic and wide sense of environment. In addition, they can be used to display 360 degree panoramic images, which is the reason why Computer Vision enthusiasts like myself are interested in this topic. To create a Skybox, you need a set of 6 images that correspond to the 6 sides of a cube. In this tutorial, we’ll learn how to create a Skybox using Qt with OpenGL.



The whole idea is to create a cube using the 6 square images as the sides, then place a camera right in the middle that can be moved around using mouse. Additionally, scrolling can be used to zoom in and out. Here is a video that depicts the Qt Widget that we’ll have by the end of this tutorial:

You can search online for countless sets of Skybox images. The one used in the preceding example is created using the following images:

Let’s start at the end. We’re looking forward to creating an OpenGL based Qt widget, called QOpenGLSkyboxWidget, that can be added to our Qt windows and used as simply as the following example:

QOpenGLSkyboxWidget widget("front.jpg",
                           "back.jpg",
                           "top.jpg",
                           "bottom.jpg",
                           "left.jpg",
                           "right.jpg");


Here is the bare minimum definition of our class, similar to any other subclass of QOpenGLWidget if you’ve seen examples on this topic before:

class QOpenGLSkyboxWidget : public QOpenGLWidget, protected QOpenGLFunctions
{
    Q_OBJECT
public:
    explicit QOpenGLSkyboxWidget(const QString& frontImagePath,
                                 const QString& backImagePath,
                                 const QString& topImagePath,
                                 const QString& bottomImagePath,
                                 const QString& leftImagePath,
                                 const QString& rightImagePath,
                                 QWidget *parent = Q_NULLPTR);

protected:
    void initializeGL() override;
    void resizeGL(int w, int h) override;
    void paintGL() override;

};

In addition, we need a bit more, in order to take care of the Skybox related stuff, zoom in and out, pan around the image and so on. Here is what you need to add to the class definition:

protected
    void mousePressEvent(QMouseEvent *e) override;
    void mouseReleaseEvent(QMouseEvent *) override;
    void mouseMoveEvent(QMouseEvent *event) override;
    void wheelEvent(QWheelEvent *event) override;

    void timerEvent(QTimerEvent *) override;

private:
    void loadImages();

    QOpenGLShaderProgram mProgram;
    QOpenGLTexture mTexture;
    QOpenGLBuffer mVertexBuf;
    QBasicTimer mTimer;

    struct
    {
        float verticalAngle;
        float aspectRatio;
        float nearPlane;
        float farPlane;
    } mPerspective;

    struct
    {
        QVector3D eye;
        QVector3D center;
        QVector3D up;
    } mLookAt;

    QMatrix4x4 mModelMat, mViewMat, mProjectionMat;

    QVector3D mRotationAxis;
    QQuaternion mRotation;

    QVector2D mMousePressPosition;
    float mSpeed;

    QString mFrontImagePath;
    QString mBackImagePath;
    QString mTopImagePath;
    QString mBottomImagePath;
    QString mLeftImagePath;
    QString mRightImagePath;

Now to the implementation part. We’ll start with the constructor:

QOpenGLSkyboxWidget::QOpenGLSkyboxWidget(const QString& frontImagePath,
                                         const QString& backImagePath,
                                         const QString& topImagePath,
                                         const QString& bottomImagePath,
                                         const QString& leftImagePath,
                                         const QString& rightImagePath,
                                         QWidget *parent) :
    QOpenGLWidget(parent),
    mTexture(QOpenGLTexture::TargetCubeMap),
    mVertexBuf(QOpenGLBuffer::VertexBuffer),
    mSpeed(0.0f),
    mFrontImagePath(frontImagePath),
    mBackImagePath(backImagePath),
    mTopImagePath(topImagePath),
    mBottomImagePath(bottomImagePath),
    mLeftImagePath(leftImagePath),
    mRightImagePath(rightImagePath)
{
    mLookAt.eye =    {+0.0f, +0.0f, +0.0f};
    mLookAt.center = {+0.0f, +0.0f, -1.0f};
    mLookAt.up =     {+0.0f, +1.0f, +0.0f};
}


Loading images into an OpenGL CubeMap texture is similar to a regular OpenGL texture, the difference is, as you can guess, setting the six sides of the cube. You need to make sure that the image format is set to QImage::Format_RGBA8888 for each individual image. Additionally, the following constants must be used for their corresponding sides of the cube we’re trying to construct:

  • Right: QOpenGLTexture::CubeMapPositiveX
  • Left: QOpenGLTexture::CubeMapNegativeX
  • Top: QOpenGLTexture::CubeMapPositiveY
  • Bottom: QOpenGLTexture::CubeMapNegativeY
  • Front: QOpenGLTexture::CubeMapPositiveZ
  • Back: QOpenGLTexture::CubeMapNegativeZ

Here is the whole loadImages method we defined for this same purpose earlier on:

void QOpenGLSkyboxWidget::loadImages()
{
    const QImage posx = QImage(mRightImagePath).convertToFormat(QImage::Format_RGBA8888);
    const QImage negx = QImage(mLeftImagePath).convertToFormat(QImage::Format_RGBA8888);

    const QImage posy = QImage(mTopImagePath).convertToFormat(QImage::Format_RGBA8888);
    const QImage negy = QImage(mBottomImagePath).convertToFormat(QImage::Format_RGBA8888);

    const QImage posz = QImage(mFrontImagePath).convertToFormat(QImage::Format_RGBA8888);
    const QImage negz = QImage(mBackImagePath).convertToFormat(QImage::Format_RGBA8888);

    mTexture.create();
    mTexture.setSize(posx.width(), posx.height(), posx.depth());
    mTexture.setFormat(QOpenGLTexture::RGBA8_UNorm);
    mTexture.allocateStorage();

    mTexture.setData(0, 0, QOpenGLTexture::CubeMapPositiveX,
                     QOpenGLTexture::RGBA, QOpenGLTexture::UInt8,
                     posx.constBits(), Q_NULLPTR);

    mTexture.setData(0, 0, QOpenGLTexture::CubeMapPositiveY,
                     QOpenGLTexture::RGBA, QOpenGLTexture::UInt8,
                     posy.constBits(), Q_NULLPTR);

    mTexture.setData(0, 0, QOpenGLTexture::CubeMapPositiveZ,
                     QOpenGLTexture::RGBA, QOpenGLTexture::UInt8,
                     posz.constBits(), Q_NULLPTR);

    mTexture.setData(0, 0, QOpenGLTexture::CubeMapNegativeX,
                     QOpenGLTexture::RGBA, QOpenGLTexture::UInt8,
                     negx.constBits(), Q_NULLPTR);

    mTexture.setData(0, 0, QOpenGLTexture::CubeMapNegativeY,
                     QOpenGLTexture::RGBA, QOpenGLTexture::UInt8,
                     negy.constBits(), Q_NULLPTR);

    mTexture.setData(0, 0, QOpenGLTexture::CubeMapNegativeZ,
                     QOpenGLTexture::RGBA, QOpenGLTexture::UInt8,
                     negz.constBits(), Q_NULLPTR);

    mTexture.setWrapMode(QOpenGLTexture::ClampToEdge);
    mTexture.setMinificationFilter(QOpenGLTexture::LinearMipMapLinear);
    mTexture.setMagnificationFilter(QOpenGLTexture::LinearMipMapLinear);
}

initializeGL needs to be reimplemented in all QOpenGLWidget subclasses and the same goes for our QOpenGLSkyboxWidget. It’s where we load the Vertex and Fragment Shader program source codes. The important part here for anyone already familiar with OpenGL is the usage of samplerCube and textureCube in Fragment Shader program. Here is the whole initializeGL reimplemented:

void QOpenGLSkyboxWidget::initializeGL()
{
    initializeOpenGLFunctions();

    mProgram.addShaderFromSourceCode(
                QOpenGLShader::Vertex,
                R"(
                attribute vec3 aPosition;
                varying vec3 vTexCoord;
                uniform mat4 mvpMatrix;

                void main()
                {
                    gl_Position = mvpMatrix * vec4(aPosition, 1.0);
                    vTexCoord = aPosition;
                }
                )");

    mProgram.addShaderFromSourceCode(
                QOpenGLShader::Fragment,
                R"(
                uniform samplerCube uTexture;
                varying vec3 vTexCoord;

                void main()
                {
                    gl_FragColor = textureCube(uTexture, vTexCoord);
                }
                )");

    mProgram.link();
    mProgram.bind();

    loadImages();

    QVector3D vertices[] =
    {
        {-1.0f,  1.0f, -1.0f},
        {-1.0f, -1.0f, -1.0f},
        {+1.0f, -1.0f, -1.0f},
        {+1.0f, -1.0f, -1.0f},
        {+1.0f, +1.0f, -1.0f},
        {-1.0f, +1.0f, -1.0f},

        {-1.0f, -1.0f, +1.0f},
        {-1.0f, -1.0f, -1.0f},
        {-1.0f, +1.0f, -1.0f},
        {-1.0f, +1.0f, -1.0f},
        {-1.0f, +1.0f, +1.0f},
        {-1.0f, -1.0f, +1.0f},

        {+1.0f, -1.0f, -1.0f},
        {+1.0f, -1.0f, +1.0f},
        {+1.0f, +1.0f, +1.0f},
        {+1.0f, +1.0f, +1.0f},
        {+1.0f, +1.0f, -1.0f},
        {+1.0f, -1.0f, -1.0f},

        {-1.0f, -1.0f, +1.0f},
        {-1.0f, +1.0f, +1.0f},
        {+1.0f, +1.0f, +1.0f},
        {+1.0f, +1.0f, +1.0f},
        {+1.0f, -1.0f, +1.0f},
        {-1.0f, -1.0f, +1.0f},

        {-1.0f, +1.0f, -1.0f},
        {+1.0f, +1.0f, -1.0f},
        {+1.0f, +1.0f, +1.0f},
        {+1.0f, +1.0f, +1.0f},
        {-1.0f, +1.0f, +1.0f},
        {-1.0f, +1.0f, -1.0f},

        {-1.0f, -1.0f, -1.0f},
        {-1.0f, -1.0f, +1.0f},
        {+1.0f, -1.0f, -1.0f},
        {+1.0f, -1.0f, -1.0f},
        {-1.0f, -1.0f, +1.0f},
        {+1.0f, -1.0f, +1.0f}
    };

    mVertexBuf.create();
    mVertexBuf.bind();
    mVertexBuf.allocate(vertices, 36 * sizeof(QVector3D));

    mProgram.enableAttributeArray("aPosition");
    mProgram.setAttributeBuffer("aPosition",
                                GL_FLOAT,
                                0,
                                3,
                                sizeof(QVector3D));

    mProgram.setUniformValue("uTexture", 0);
}


paintGL is where the OpenGL drawing command will be issues and the rendering will happen. This part shouldn’t be a surprise at all, however, if you’re not familiar with OpenGL Coordinate Systems, consider reading about Model-View-Projection and its usage for simplifying the rendering of 3D objects.

void QOpenGLSkyboxWidget::paintGL()
{
    mTexture.bind();

    mModelMat.setToIdentity();

    mViewMat.setToIdentity();
    mViewMat.lookAt(mLookAt.eye,
                    mLookAt.center,
                    mLookAt.up);

    mProjectionMat.setToIdentity();
    mProjectionMat.perspective(mPerspective.verticalAngle,
                               mPerspective.aspectRatio,
                               mPerspective.nearPlane,
                               mPerspective.farPlane);

    mProgram.setUniformValue("mvpMatrix", mProjectionMat * mViewMat * mModelMat);

    glDrawArrays(GL_TRIANGLES,
                 0,
                 36);
}

resizeGL is the best place to adjust the view. Here is how we use this method to consider aspect ratio and other view related parameter. Consider this part as the place where we adjust the camera that looks into the whole environment we’re creating.

void QOpenGLSkyboxWidget::resizeGL(int w, int h)
{
    mPerspective.verticalAngle = 60.0;
    mPerspective.nearPlane = 0.0;
    mPerspective.farPlane = 1.0;
    mPerspective.aspectRatio =
            static_cast<float>(w) / static_cast<float>(h ? h : 1.0f);
}

Panning around the images and essentially the environment requires handling all mouse events in a way that feels natural for the user. Here are all functions needed for this same purpose:

void QOpenGLSkyboxWidget::mouseMoveEvent(QMouseEvent *event)
{
    if(event->buttons() & Qt::LeftButton)
    {
        auto diff = QVector2D(event->localPos()) - mMousePressPosition;
        auto n = QVector3D(diff.y(), diff.x(), 0.0).normalized();
        mSpeed = diff.length() / 100.0f;
        if(mSpeed > 1.0f) mSpeed = 1.0f; // speed threshold
        mRotationAxis = (mRotationAxis + n * mSpeed).normalized();
    }
}

void QOpenGLSkyboxWidget::mousePressEvent(QMouseEvent *event)
{
    mMousePressPosition = QVector2D(event->localPos());
    mTimer.start(10, this);
}

void QOpenGLSkyboxWidget::mouseReleaseEvent(QMouseEvent*)
{
    mTimer.stop();
}

void QOpenGLSkyboxWidget::timerEvent(QTimerEvent *)
{
    mRotation = QQuaternion::fromAxisAndAngle(mRotationAxis, mSpeed) * mRotation;

    QMatrix4x4 mat;
    mat.setToIdentity();
    mat.rotate(mRotation);

    mLookAt.center = {+0.0f, +0.0f, -1.0f};
    mLookAt.center = mLookAt.center * mat;

    update();
}


Finally, zooming in and out of the image requires the handling of wheelEvent as seen here:

void QOpenGLSkyboxWidget::wheelEvent(QWheelEvent *event)
{
    float delta = event->delta() > 0 ? -5.0f : +5.0f;
    mPerspective.verticalAngle += delta;
    if(mPerspective.verticalAngle < 10.0f)
        mPerspective.verticalAngle = 10.0f;
    else if(mPerspective.verticalAngle > 120.0f)
        mPerspective.verticalAngle = 120.0f;
    
    update();
}


Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.