Examples » Model viewer

Scene graph, resource management and model importing.

Image

In this example we will import a 3D scene file and display it interactively on the screen. The tutorial covers these new features:

Scene graph

In previous examples we managed our scene manually, because there was just one object. However, as object count increases, it's better to have the objects organized in a scene graph. Magnum SceneGraph takes care of three key things:

  • parent/child object relations
  • hierarchic object transformations
  • attaching features to objects (drawing, animation, ...)

For a scene there is one root SceneGraph::Scene object and some SceneGraph::Object instances. Each object can have a parent and maintains a list of its children. This hierarchy is used also to simplify memory management — when destroying any object, all its children and features are recursively destroyed too.

Each particular scene graph uses some transformation implementation, which stores transformation for each object (relative to parent) and provides convenience functions for most used transformations, like translation, rotation and scaling. It is also possible to calculate an absolute transformation or transformation relative to some arbitrary object in the same scene.

Features are added to objects to make them do something useful. The most common feature, which will be also used in this example, is SceneGraph::Drawable. When implemented, it allows the object to be drawn on the screen. Each drawable is part of some SceneGraph::DrawableGroup and this group can be then rendered in one shot using SceneGraph::Camera. The camera is also a feature — it handles various projection parameters and is attached to an object that controls its transformation in the scene.

Magnum scene graph implementation works for both 2D and 3D scenes. Their usage is nearly the same and differs only in obvious ways (e.g. perspective projection is not available in 2D).

See also Using scene graph for more detailed introduction.

Setting up and initializing the scene graph

As we are importing a complete scene, we need quite a lot of things to handle materials, meshes and textures:

#include <Corrade/Containers/Array.h>
#include <Corrade/PluginManager/Manager.h>
#include <Corrade/Utility/Arguments.h>
#include <Magnum/Mesh.h>
#include <Magnum/PixelFormat.h>
#include <Magnum/GL/DefaultFramebuffer.h>
#include <Magnum/GL/Mesh.h>
#include <Magnum/GL/Renderer.h>
#include <Magnum/GL/Texture.h>
#include <Magnum/GL/TextureFormat.h>
#include <Magnum/MeshTools/Compile.h>
#include <Magnum/Platform/Sdl2Application.h>
#include <Magnum/SceneGraph/Camera.h>
#include <Magnum/SceneGraph/Drawable.h>
#include <Magnum/SceneGraph/MatrixTransformation3D.h>
#include <Magnum/SceneGraph/Scene.h>
#include <Magnum/Shaders/Phong.h>
#include <Magnum/Trade/AbstractImporter.h>
#include <Magnum/Trade/ImageData.h>
#include <Magnum/Trade/MeshData3D.h>
#include <Magnum/Trade/MeshObjectData3D.h>
#include <Magnum/Trade/PhongMaterialData.h>
#include <Magnum/Trade/SceneData.h>
#include <Magnum/Trade/TextureData.h>

For this example we will use scene graph with SceneGraph::MatrixTransformation3D as transformation implementation. It is a good default choice, if you don't want to be limited in how you transform the objects, but on the other hand it eats up more memory and is slightly slower than for example SceneGraph::DualQuaternionTransformation implementation. We typedef the classes to save us more typing:

typedef SceneGraph::Object<SceneGraph::MatrixTransformation3D> Object3D;
typedef SceneGraph::Scene<SceneGraph::MatrixTransformation3D> Scene3D;

Our main class stores shader instances for rendering colored and textured objects and all imported meshes and textures. After that, there is the scene graph — root scene instance, a manipulator object for easy interaction with the scene, object holding the camera, the actual camera instance and a group of all drawables in the scene.

class ViewerExample: public Platform::Application {
    public:
        explicit ViewerExample(const Arguments& arguments);

    private:
        void drawEvent() override;
        void viewportEvent(ViewportEvent& event) override;
        void mousePressEvent(MouseEvent& event) override;
        void mouseReleaseEvent(MouseEvent& event) override;
        void mouseMoveEvent(MouseMoveEvent& event) override;
        void mouseScrollEvent(MouseScrollEvent& event) override;

        Vector3 positionOnSphere(const Vector2i& position) const;

        void addObject(Trade::AbstractImporter& importer, Containers::ArrayView<const Containers::Optional<Trade::PhongMaterialData>> materials, Object3D& parent, UnsignedInt i);

        Shaders::Phong _coloredShader,
            _texturedShader{Shaders::Phong::Flag::DiffuseTexture};
        Containers::Array<Containers::Optional<GL::Mesh>> _meshes;
        Containers::Array<Containers::Optional<GL::Texture2D>> _textures;

        Scene3D _scene;
        Object3D _manipulator, _cameraObject;
        SceneGraph::Camera3D* _camera;
        SceneGraph::DrawableGroup3D _drawables;
        Vector3 _previousPosition;
};

In the constructor we first parse command-line arguments using Utility::Arguments. At the very least we need a filename to load. Magnum itself is also able to consume command-line arguments, for example to control DPI scaling or driver-specific options. All of them start with --magnum- and addSkippedPrefix() makes it possible to specify them instead of failing with unknown argument error.

ViewerExample::ViewerExample(const Arguments& arguments):
    Platform::Application{arguments, Configuration{}
        .setTitle("Magnum Viewer Example")
        .setWindowFlags(Configuration::WindowFlag::Resizable)}
{
    Utility::Arguments args;
    args.addArgument("file").setHelp("file", "file to load")
        .addOption("importer", "AnySceneImporter").setHelp("importer", "importer plugin to use")
        .addSkippedPrefix("magnum").setHelp("engine-specific options")
        .setHelp("Displays a 3D scene file provided on command line.")
        .parse(arguments.argc, arguments.argv);

Then we setup the scene:

    /* Every scene needs a camera */
    _cameraObject
        .setParent(&_scene)
        .translate(Vector3::zAxis(5.0f));
    (*(_camera = new SceneGraph::Camera3D{_cameraObject}))
        .setAspectRatioPolicy(SceneGraph::AspectRatioPolicy::Extend)
        .setProjectionMatrix(Matrix4::perspectiveProjection(35.0_degf, 1.0f, 0.01f, 1000.0f))
        .setViewport(GL::defaultFramebuffer.viewport().size());

    /* Base object, parent of all (for easy manipulation) */
    _manipulator.setParent(&_scene);

After that, we enable depth test and face culling for correct and fast rendering and set some shader defaults that will stay the same during the whole time. Note that these properties are usually part of the imported material data, but to keep the example simple we'll be only using the diffuse color and texture properties.

    /* Setup renderer and shader defaults */
    GL::Renderer::enable(GL::Renderer::Feature::DepthTest);
    GL::Renderer::enable(GL::Renderer::Feature::FaceCulling);
    _coloredShader
        .setAmbientColor(0x111111_rgbf)
        .setSpecularColor(0xffffff_rgbf)
        .setShininess(80.0f);
    _texturedShader
        .setAmbientColor(0x111111_rgbf)
        .setSpecularColor(0x111111_rgbf)
        .setShininess(80.0f);

Importing the data

For scene import we are using the AnySceneImporter plugin, which detects format based or file extension and then redirects the actual loading to a plugin dedicated for given format. So, for example, if you open scene.ogex, the opening is done through OpenGexImporter, file named mesh.obj is opened using ObjImporter etc. Sometimes there is more than one possible choice (for example glTF files can be opened using both TinyGltfImporter and AssimpImporter) and so above we made it possible to override the used importer plugin using the "importer" command-line argument. Another option is to specify the preference using Corrade::PluginManager::Manager::setPreferredPlugins().

We try load and instantiate the plugin and open the file. If any operation fails, the application simply exits. The plugins print a message on error, so it's usually not needed to repeat it in application code.

    /* Load a scene importer plugin */
    PluginManager::Manager<Trade::AbstractImporter> manager;
    std::unique_ptr<Trade::AbstractImporter> importer = manager.loadAndInstantiate(args.value("importer"));
    if(!importer) std::exit(1);

    Debug{} << "Opening file" << args.value("file");

    /* Load file */
    if(!importer->openFile(args.value("file")))
        std::exit(4);

First we import all textures. The textures are stored in an array of Containers::Optional objects, so if importing a texture fails, given slot is set to Containers::NullOpt to indicate the unavailability. For simplicity we'll import only 8-bit-per-channel RGB or RGBA textures.

Most scene importers internally use AnyImageImporter for loading images from external files. It is similar to AnySceneImporter, but specialized for image loading. For example if the textures references image.png, it gets opened through PngImporter, texture.jpg through JpegImporter etc. The plugins also have aliases, so for example on platforms that don't have libPNG available, dependency-less StbImageImporter can be transparently used in place of PngImporter without changing anything in the loading code. The ultimate goal is that you can deploy different set of plugins for each platform but still use them in platform-independent way, without worrying about which plugin might be available on which system.

    /* Load all textures. Textures that fail to load will be NullOpt. */
    _textures = Containers::Array<Containers::Optional<GL::Texture2D>>{importer->textureCount()};
    for(UnsignedInt i = 0; i != importer->textureCount(); ++i) {
        Debug{} << "Importing texture" << i << importer->textureName(i);

        Containers::Optional<Trade::TextureData> textureData = importer->texture(i);
        if(!textureData || textureData->type() != Trade::TextureData::Type::Texture2D) {
            Warning{} << "Cannot load texture properties, skipping";
            continue;
        }

        Debug{} << "Importing image" << textureData->image() << importer->image2DName(textureData->image());

        Containers::Optional<Trade::ImageData2D> imageData = importer->image2D(textureData->image());
        GL::TextureFormat format;
        if(imageData && imageData->format() == PixelFormat::RGB8Unorm)
            format = GL::TextureFormat::RGB8;
        else if(imageData && imageData->format() == PixelFormat::RGBA8Unorm)
            format = GL::TextureFormat::RGBA8;
        else {
            Warning{} << "Cannot load texture image, skipping";
            continue;
        }

        /* Configure the texture */
        GL::Texture2D texture;
        texture
            .setMagnificationFilter(textureData->magnificationFilter())
            .setMinificationFilter(textureData->minificationFilter(), textureData->mipmapFilter())
            .setWrapping(textureData->wrapping().xy())
            .setStorage(Math::log2(imageData->size().max()) + 1, format, imageData->size())
            .setSubImage(0, {}, *imageData)
            .generateMipmap();

        _textures[i] = std::move(texture);
    }

After that we import all materials. The material data are stored only temporarily, because we'll later extract only the data we need from them. Again, for simplicity, we'll restrict the loading only to Phong-based materials.

    /* Load all materials. Materials that fail to load will be NullOpt. The
       data will be stored directly in objects later, so save them only
       temporarily. */
    Containers::Array<Containers::Optional<Trade::PhongMaterialData>> materials{importer->materialCount()};
    for(UnsignedInt i = 0; i != importer->materialCount(); ++i) {
        Debug{} << "Importing material" << i << importer->materialName(i);

        std::unique_ptr<Trade::AbstractMaterialData> materialData = importer->material(i);
        if(!materialData || materialData->type() != Trade::MaterialType::Phong) {
            Warning{} << "Cannot load material, skipping";
            continue;
        }

        materials[i] = std::move(static_cast<Trade::PhongMaterialData&>(*materialData));
    }

Next thing is loading meshes. Because the models might or might not be textured, the mesh might or might not be indexed etc., the mesh creation procedure can get fairly long-winded. There is a convenience MeshTools::compile() function which examines the data, adds all available vertex attributes to the buffer (normals, texture coordinates...), packs the indices (if any) and then configures the mesh for the Shaders::Generic shader, from which all other stock shaders are derived. This function is useful for exactly this case of importing general meshes — but the lower-level way involving MeshTools::interleave() and MeshTools::compressIndices() as explained in the earlier Primitives example is far more flexible if you want data packing, index optimization and other performance-related stuff. The only case that the following code does not handle are meshes without normals (as is common with files in Stanford/PLY format), there the normals would need to be generated to have the mesh displayed with proper lighting.

    /* Load all meshes. Meshes that fail to load will be NullOpt. */
    _meshes = Containers::Array<Containers::Optional<GL::Mesh>>{importer->mesh3DCount()};
    for(UnsignedInt i = 0; i != importer->mesh3DCount(); ++i) {
        Debug{} << "Importing mesh" << i << importer->mesh3DName(i);

        Containers::Optional<Trade::MeshData3D> meshData = importer->mesh3D(i);
        if(!meshData || !meshData->hasNormals() || meshData->primitive() != MeshPrimitive::Triangles) {
            Warning{} << "Cannot load the mesh, skipping";
            continue;
        }

        /* Compile the mesh */
        _meshes[i] = MeshTools::compile(*meshData);
    }

Last reamining part is to populate the actual scene. If the format supports scene hierarchy, we recursively import all objects in the scene. If it doesn't (which is the case for the simplest mesh formats), we just add a single object with the first imported mesh and put a simple color-only material on it.

    /* Load the scene */
    if(importer->defaultScene() != -1) {
        Debug{} << "Adding default scene" << importer->sceneName(importer->defaultScene());

        Containers::Optional<Trade::SceneData> sceneData = importer->scene(importer->defaultScene());
        if(!sceneData) {
            Error{} << "Cannot load scene, exiting";
            return;
        }

        /* Recursively add all children */
        for(UnsignedInt objectId: sceneData->children3D())
            addObject(*importer, materials, _manipulator, objectId);

    /* The format has no scene support, display just the first loaded mesh with
       a default material and be done with it */
    } else if(!_meshes.empty() && _meshes[0])
        new ColoredDrawable{_manipulator, _coloredShader, *_meshes[0], 0xffffff_rgbf, _drawables};

The actual function that adds objects into the scene isn't very complex. First it creates the object with correct parent and transformation, then attaches either a colored or texture drawable feature to it (more on these two below) and then recursively calls itself on all object children. Again, for simplicity, only diffuse texture is considered in this example.

void ViewerExample::addObject(Trade::AbstractImporter& importer, Containers::ArrayView<const Containers::Optional<Trade::PhongMaterialData>> materials, Object3D& parent, UnsignedInt i) {
    Debug{} << "Importing object" << i << importer.object3DName(i);
    std::unique_ptr<Trade::ObjectData3D> objectData = importer.object3D(i);
    if(!objectData) {
        Error{} << "Cannot import object, skipping";
        return;
    }

    /* Add the object to the scene and set its transformation */
    auto* object = new Object3D{&parent};
    object->setTransformation(objectData->transformation());

    /* Add a drawable if the object has a mesh and the mesh is loaded */
    if(objectData->instanceType() == Trade::ObjectInstanceType3D::Mesh && objectData->instance() != -1 && _meshes[objectData->instance()]) {
        const Int materialId = static_cast<Trade::MeshObjectData3D*>(objectData.get())->material();

        /* Material not available / not loaded, use a default material */
        if(materialId == -1 || !materials[materialId]) {
            new ColoredDrawable{*object, _coloredShader, *_meshes[objectData->instance()], 0xffffff_rgbf, _drawables};

        /* Textured material. If the texture failed to load, again just use a
           default colored material. */
        } else if(materials[materialId]->flags() & Trade::PhongMaterialData::Flag::DiffuseTexture) {
            Containers::Optional<GL::Texture2D>& texture = _textures[materials[materialId]->diffuseTexture()];
            if(texture)
                new TexturedDrawable{*object, _texturedShader, *_meshes[objectData->instance()], *texture, _drawables};
            else
                new ColoredDrawable{*object, _coloredShader, *_meshes[objectData->instance()], 0xffffff_rgbf, _drawables};

        /* Color-only material */
        } else {
            new ColoredDrawable{*object, _coloredShader, *_meshes[objectData->instance()], materials[materialId]->diffuseColor(), _drawables};
        }
    }

    /* Recursively add children */
    for(std::size_t id: objectData->children())
        addObject(importer, materials, *object, id);
}

Drawable objects

As explained above, all objects that want to draw something on the screen using the scene graph do that using the SceneGraph::Drawable feature. It can be either subclassed separately or added through multiple inheritance. In this example we'll use the former, see Object features for details on all possibilities.

The subclass stores everything needed to render either the colored or the textured object — reference to a shader, a mesh and a color or a texture. The constructor takes care of passing the containing object and a drawable group to the superclass.

class ColoredDrawable: public SceneGraph::Drawable3D {
    public:
        explicit ColoredDrawable(Object3D& object, Shaders::Phong& shader, GL::Mesh& mesh, const Color4& color, SceneGraph::DrawableGroup3D& group): SceneGraph::Drawable3D{object, &group}, _shader(shader), _mesh(mesh), _color{color} {}

    private:
        void draw(const Matrix4& transformationMatrix, SceneGraph::Camera3D& camera) override;

        Shaders::Phong& _shader;
        GL::Mesh& _mesh;
        Color4 _color;
};

class TexturedDrawable: public SceneGraph::Drawable3D {
    public:
        explicit TexturedDrawable(Object3D& object, Shaders::Phong& shader, GL::Mesh& mesh, GL::Texture2D& texture, SceneGraph::DrawableGroup3D& group): SceneGraph::Drawable3D{object, &group}, _shader(shader), _mesh(mesh), _texture(texture) {}

    private:
        void draw(const Matrix4& transformationMatrix, SceneGraph::Camera3D& camera) override;

        Shaders::Phong& _shader;
        GL::Mesh& _mesh;
        GL::Texture2D& _texture;
};

Each drawable needs to implement the draw() function. It's nothing more than setting up shader parameters and drawing the mesh. To keep things simple, the example uses a fixed global light position — though it's possible to import the light position and other properties as well, if the file has them.

void ColoredDrawable::draw(const Matrix4& transformationMatrix, SceneGraph::Camera3D& camera) {
    _shader
        .setDiffuseColor(_color)
        .setLightPosition(camera.cameraMatrix().transformPoint({-3.0f, 10.0f, 10.0f}))
        .setTransformationMatrix(transformationMatrix)
        .setNormalMatrix(transformationMatrix.rotation())
        .setProjectionMatrix(camera.projectionMatrix());

    _mesh.draw(_shader);
}

void TexturedDrawable::draw(const Matrix4& transformationMatrix, SceneGraph::Camera3D& camera) {
    _shader
        .setLightPosition(camera.cameraMatrix().transformPoint({-3.0f, 10.0f, 10.0f}))
        .setTransformationMatrix(transformationMatrix)
        .setNormalMatrix(transformationMatrix.rotation())
        .setProjectionMatrix(camera.projectionMatrix())
        .bindDiffuseTexture(_texture);

    _mesh.draw(_shader);
}

Finally, the draw event only delegates to the camera, which draws everything in our drawable group.

void ViewerExample::drawEvent() {
    GL::defaultFramebuffer.clear(GL::FramebufferClear::Color|GL::FramebufferClear::Depth);

    _camera->draw(_drawables);

    swapBuffers();
}

Event handling

This example has a resizable window, for which we need to implement the viewport event. It simply delegates the size change to GL viewport and to the camera.

void ViewerExample::viewportEvent(ViewportEvent& event) {
    GL::defaultFramebuffer.setViewport({{}, event.framebufferSize()});
    _camera->setViewport(event.windowSize());
}

Lastly there is mouse handling to rotate and zoom the scene around, nothing new to talk about.

void ViewerExample::mousePressEvent(MouseEvent& event) {
    if(event.button() == MouseEvent::Button::Left)
        _previousPosition = positionOnSphere(event.position());
}

void ViewerExample::mouseReleaseEvent(MouseEvent& event) {
    if(event.button() == MouseEvent::Button::Left)
        _previousPosition = Vector3();
}

void ViewerExample::mouseScrollEvent(MouseScrollEvent& event) {
    if(!event.offset().y()) return;

    /* Distance to origin */
    const Float distance = _cameraObject.transformation().translation().z();

    /* Move 15% of the distance back or forward */
    _cameraObject.translate(Vector3::zAxis(
        distance*(1.0f - (event.offset().y() > 0 ? 1/0.85f : 0.85f))));

    redraw();
}

Vector3 ViewerExample::positionOnSphere(const Vector2i& position) const {
    const Vector2 positionNormalized = Vector2{position}/Vector2{_camera->viewport()} - Vector2{0.5f};
    const Float length = positionNormalized.length();
    const Vector3 result(length > 1.0f ? Vector3(positionNormalized, 0.0f) : Vector3(positionNormalized, 1.0f - length));
    return (result*Vector3::yScale(-1.0f)).normalized();
}

void ViewerExample::mouseMoveEvent(MouseMoveEvent& event) {
    if(!(event.buttons() & MouseMoveEvent::Button::Left)) return;

    const Vector3 currentPosition = positionOnSphere(event.position());
    const Vector3 axis = Math::cross(_previousPosition, currentPosition);

    if(_previousPosition.length() < 0.001f || axis.length() < 0.001f) return;

    _manipulator.rotate(Math::angle(_previousPosition, currentPosition), axis.normalized());
    _previousPosition = currentPosition;

    redraw();
}

Compilation

Compilation is again nothing special:

find_package(Magnum REQUIRED
    GL
    MeshTools
    Shaders
    SceneGraph
    Trade
    Sdl2Application)

set_directory_properties(PROPERTIES CORRADE_USE_PEDANTIC_FLAGS ON)

add_executable(magnum-viewer ViewerExample.cpp)
target_link_libraries(magnum-viewer PRIVATE
    Magnum::Application
    Magnum::GL
    Magnum::Magnum
    Magnum::MeshTools
    Magnum::SceneGraph
    Magnum::Shaders
    Magnum::Trade)

You can experiment by loading scenes of varying complexity and formats, adding light and camera property import or supporting more than just diffuse Phong materials. The full file content is linked below. Full source code is also available in the magnum-examples GitHub repository.

The ports branch contains additional patches for Emscripten support that aren't present in master in order to keep the example code as simple as possible.